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())
+2
View File
@@ -1,5 +1,7 @@
# Start the Arcade stack
To start the Pi applications automatically from the PC controller command, follow the [remote Pi startup guide](../../docs/remote_pi_start.md).
For the assigned internal operator/Cabinet 1–7 tokens and per-machine Godot CFG examples, see the [cabinet setup guide](../../docs/cabinet_setup.md).
One launcher starts the needed processes, waits for services to respond, saves logs, and stops its children when you press Ctrl+C. It runs on Windows and Linux/Pi (and supports controller-only use on macOS).
-12
View File
@@ -1,12 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
; Paths are relative to THIS file. Reuse your existing database path when upgrading.
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
; Use your existing tokens. Leave blank if supplied via environment variables.
operator_token =
cabinet_tokens = {}
+32 -4
View File
@@ -70,6 +70,10 @@ def plan(config_path, mode):
url_host = '127.0.0.1' if bind == '0.0.0.0' else bind
url = f'http://{url_host}:{port}'
jobs.append(dict(name='controller', command=[sys.executable, '-u', str(script), '--bind', bind, '--port', str(port), '--database', str(database), '--catalog', str(catalog)], env=server_env, port=(bind, port), ready=url+'/', directory=database.parent))
if value('controller', 'hub_config'):
hub_path = path('controller', 'hub_config', '')
require_file(hub_path)
jobs[-1]['command'] += ['--hub-config', str(hub_path)]
if mode in ('cabinet', 'all'):
executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64')
require_file(executable)
@@ -92,13 +96,21 @@ def plan(config_path, mode):
if not sys.platform.startswith('linux'): raise ValueError('Real GPIO requires the Linux Pi; set gpio = false on Windows')
if importlib.util.find_spec('gpiozero') is None or importlib.util.find_spec('lgpio') is None:
raise ValueError('Install Pi dependencies first: sudo apt install python3-gpiozero python3-lgpio')
pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',')]
if not pins or len(set(pins)) != len(pins) or any(p < 0 or p > 27 for p in pins): raise ValueError('GPIO pins must be unique BCM numbers 0..27')
pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',') if p.strip()]
if len(set(pins)) != len(pins) or any(p < 0 or p > 27 for p in pins): raise ValueError('GPIO pins must be unique BCM numbers 0..27')
script = ROOT / 'tools/pi_gpio/pi_gpio_bridge.py'
require_file(script)
ledger = path('gpio', 'ledger', '~/.local/state/arcade_gpio/requests.sqlite3')
bridge_env = dict(env, GPIOZERO_PIN_FACTORY='lgpio')
jobs.append(dict(name='gpio', command=[sys.executable, '-u', str(script), '--controller', value('gpio', 'controller', cabinet_id+'_gpio'), '--pins', ','.join(map(str,pins)), '--ledger', str(ledger)], env=bridge_env, port=('127.0.0.1', 8765), ready='http://127.0.0.1:8765/heartbeat', directory=ledger.parent))
if value('gpio', 'devices'):
devices_path = path('gpio', 'devices', '')
require_file(devices_path)
jobs[-1]['command'] += ['--devices', str(devices_path)]
if value('gpio', 'rfid_helper'):
helper_path = path('gpio', 'rfid_helper', '')
require_file(helper_path)
jobs[-1]['command'] += ['--rfid-helper', str(helper_path)]
args = [str(executable), '--', '--managed-cabinet', '--cabinet='+cabinet_id]
if real_gpio: args.append('--gpio=real')
jobs.append(dict(name='cabinet', command=args, env=cabinet_env, port=None, ready=None, directory=executable.parent))
@@ -127,7 +139,7 @@ def wait_ready(job, process):
raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False):
def run(jobs, logs, open_browser=False, remote_targets=None):
check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True)
children, files = [], []
@@ -144,6 +156,9 @@ def run(jobs, logs, open_browser=False):
if job['name'] == 'controller':
print('Operator: '+job['ready'], flush=True)
if open_browser: webbrowser.open(job['ready'])
if remote_targets:
from remote_cabinets import start_and_check
start_and_check(remote_targets, job)
print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True)
while True:
for job, process in children:
@@ -178,12 +193,25 @@ def main():
try:
print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True)
jobs, logs = plan(args.config, args.mode)
from remote_cabinets import load_targets
remote_targets = load_targets(args.config, args.mode)
if remote_targets:
controller = next(j for j in jobs if j['name'] == 'controller')
catalog_path = controller['command'][controller['command'].index('--catalog')+1]
known = json.loads(Path(catalog_path).read_text(encoding='utf-8'))['cabinets']
tokens = json.loads(controller['env']['ARCADE_CABINET_TOKENS'])
for target in remote_targets:
if target['id'] not in known or target['id'] not in tokens:
raise ValueError('Remote cabinet must be registered and have a controller token: '+target['id'])
if any(j['name']=='cabinet' and '--cabinet='+target['id'] in j['command'] for j in jobs):
raise ValueError('Cannot launch the same cabinet identity locally and remotely')
if args.check:
check_ports(jobs)
print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs))
print('Logs: '+str(logs))
print('Remote start targets (not contacted by --check): '+', '.join(t['id'] for t in remote_targets))
return 0
return run(jobs, logs, args.browser)
return run(jobs, logs, args.browser, remote_targets)
except (ValueError, OSError, RuntimeError, configparser.Error) as exc:
print('Startup failed: '+str(exc), file=sys.stderr)
return 1
+10
View File
@@ -0,0 +1,10 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token = Yu1qH7nz_aMUTlkk
cabinet_tokens = {"cabinet_01":"K32qg0lYioXmzWAK","cabinet_02":"LY9ATvM3wkTxJg0x","cabinet_03":"qZSD9eM2dmeRtmVA","cabinet_04":"2xXcj0sUbOzQCn2j","cabinet_05":"weISFvFBOt6pxfk-","cabinet_06":"h-lP-SfQMXT_dLtc","cabinet_07":"pUT4GdBYtW3slhOq"}
+7
View File
@@ -0,0 +1,7 @@
[launcher]
logs = logs
[cabinet]
id = cabinet_03
url = 10.0.0.57
token = qZSD9eM2dmeRtmVA
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Install a user service from a Pi desktop terminal; no root or SSH changes."""
import argparse
import os
from pathlib import Path
import subprocess
import sys
HERE = Path(__file__).resolve().parent
MARKER = '# Managed by Arcade Escape pi_service.py'
def quoted(value):
value=str(value)
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Paths must not contain control characters')
return '"'+value.replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"'
def working_directory(value):
# Unlike ExecStart arguments, systemd reads this as a single unquoted path.
value = str(value)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError('Paths must not contain control characters')
return value.replace('%', '%%')
def reload_service():
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
result = subprocess.run(
['systemctl', '--user', 'show', 'arcade-cabinet.service',
'--property=LoadState', '--value'],
check=True, capture_output=True, text=True)
if result.stdout.strip() != 'loaded':
raise ValueError(
'systemd rejected arcade-cabinet.service (LoadState: '
+ result.stdout.strip() + '). Inspect it with: '
'systemctl --user status arcade-cabinet.service --no-pager -l')
def render_unit(python, launcher, config):
return f"""{MARKER}
[Unit]
Description=Arcade Escape managed cabinet
PartOf=graphical-session.target
[Service]
Type=exec
EnvironmentFile=%t/arcade-cabinet-display.env
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
WorkingDirectory={working_directory(Path(launcher).resolve().parents[2])}
ExecStart=:{quoted(python)} {quoted(launcher)} cabinet --config {quoted(config)}
Restart=no
KillMode=mixed
TimeoutStopSec=15
"""
def render_desktop(python, script):
# Desktop Exec quoting also needs escaping of $ and backticks inside quotes.
def desktop_quote(value):
return quoted(value).replace('$','\\$').replace('`','\\`')
return f"""{MARKER}
[Desktop Entry]
Type=Application
Name=Arcade cabinet display setup
Exec={desktop_quote(python)} {desktop_quote(script)} refresh-session
Terminal=false
NoDisplay=true
"""
def refresh_session():
if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'):
raise ValueError('Run this from a terminal on the Pi desktop, not a plain SSH session')
runtime=Path(os.environ.get('XDG_RUNTIME_DIR',f'/run/user/{os.getuid()}'))
if not runtime.is_dir(): raise ValueError('The Pi desktop user runtime directory is unavailable')
values={k:os.environ[k] for k in ('DISPLAY','WAYLAND_DISPLAY','XAUTHORITY','XDG_RUNTIME_DIR','XDG_SESSION_TYPE') if os.environ.get(k)}
text=''
for key,value in values.items():
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Invalid desktop environment value')
text+=key+'="'+value.replace('\\','\\\\').replace('"','\\"')+'"\n'
target=runtime/'arcade-cabinet-display.env'
temporary=target.with_suffix('.tmp')
temporary.write_text(text,encoding='utf-8')
temporary.chmod(0o600)
temporary.replace(target)
print('Saved display environment for the current desktop login.')
def write_managed(path, text):
if path.exists() and not path.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(path))
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(text,encoding='utf-8')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['install','refresh-session'])
parser.add_argument('--config',default=str(HERE/'launcher.ini'))
parser.add_argument('--remote', action='store_true', help='Register from SSH; wait for the desktop helper if no display is available')
args=parser.parse_args()
try:
if not sys.platform.startswith('linux') or os.getuid()==0:
raise ValueError('Run as the normal Pi desktop user on Linux, without sudo')
if args.action=='refresh-session':
refresh_session()
return 0
from launch import plan
config=Path(args.config).resolve()
# Validate against the user's actual config; do not start games or GPIO here.
plan(config,'cabinet')
if not args.remote or os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY'):
refresh_session()
else:
print('Remote installation: display helper will capture settings at the next desktop login.')
home=Path.home()/'.config'
unit=home/'systemd/user/arcade-cabinet.service'
desktop=home/'autostart/arcade-cabinet-display.desktop'
unit_text=render_unit(sys.executable,HERE/'launch.py',config)
desktop_text=render_desktop(sys.executable,Path(__file__).resolve())
for target in (unit,desktop):
if target.exists() and not target.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(target))
write_managed(unit,unit_text)
write_managed(desktop,desktop_text)
reload_service()
print('Installed arcade-cabinet.service (not started and not enabled at boot).')
print('Start locally: systemctl --user start arcade-cabinet.service')
print('The display helper will refresh settings at future desktop logins.')
return 0
except (ValueError,OSError,subprocess.CalledProcessError) as exc:
print('Pi service setup failed: '+str(exc),file=sys.stderr)
return 1
if __name__=='__main__': sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
; Merge this section into the PC controller's existing launcher INI.
; Replace the hosts and usernames before setting enabled = true.
[remote]
enabled = false
cabinets = [{"id":"cabinet_01","host":"PI_1_HOST","user":"PI_USER"},{"id":"cabinet_02","host":"PI_2_HOST","user":"PI_USER"},{"id":"cabinet_03","host":"PI_3_HOST","user":"PI_USER"}]
+73
View File
@@ -0,0 +1,73 @@
"""Explicit SSH service starts; no remote shell interpolation or automatic restarts."""
import concurrent.futures
import configparser
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import urllib.request
def load_targets(config_path, mode):
if mode not in ('controller', 'all'): return []
config=configparser.ConfigParser(interpolation=None)
config.read(config_path, encoding='utf-8-sig')
if not config.getboolean('remote', 'enabled', fallback=False): return []
raw=json.loads(config.get('remote', 'cabinets', fallback='[]'))
if not isinstance(raw,list) or not raw: raise ValueError('remote.cabinets must be a nonempty JSON list')
ssh=shutil.which('ssh')
if not ssh: raise ValueError('OpenSSH client (ssh) is required for remote cabinet startup')
targets=[]
seen=set()
for item in raw:
if not isinstance(item,dict): raise ValueError('Each remote cabinet must be an object')
cabinet,host,user=(item.get(k,'') for k in ('id','host','user'))
for value,pattern,label in [(cabinet,r'[A-Za-z0-9_]+','cabinet ID'),(host,r'[A-Za-z0-9][A-Za-z0-9.-]*','host'),(user,r'[A-Za-z_][A-Za-z0-9_-]*','SSH user')]:
if not isinstance(value,str) or not re.fullmatch(pattern,value): raise ValueError('Invalid remote '+label)
if cabinet in seen: raise ValueError('Duplicate remote cabinet ID')
seen.add(cabinet)
port=item.get('port',22)
if type(port) is not int or not 1<=port<=65535: raise ValueError('Invalid SSH port')
command=[ssh,'-T','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=5','-o','ConnectionAttempts=1','-p',str(port)]
if item.get('identity_file'):
key=(Path(config_path).resolve().parent / Path(item['identity_file']).expanduser()).resolve()
if not key.is_file(): raise ValueError('SSH identity file not found: '+str(key))
command+=['-i',str(key),'-o','IdentitiesOnly=yes']
command += [user+'@'+host,'systemctl','--user','start','arcade-cabinet.service']
targets.append(dict(id=cabinet,host=host,command=command))
return targets
def start_one(target):
try:
result=subprocess.run(target['command'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=15,creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)
if result.returncode:
return target['id'],False,(result.stderr or result.stdout).strip()[-1500:]
return target['id'],True,'Service start accepted (already-running services are left running)'
except (OSError,subprocess.TimeoutExpired) as exc:
return target['id'],False,str(exc)
def start_and_check(targets,controller):
if not targets: return
print('Starting configured Pi services over SSH...',flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
for cabinet,ok,detail in executor.map(start_one,targets):
print(f"{cabinet}: {'SSH OK' if ok else 'SSH FAILED'} - {detail}",flush=True)
pending={t['id'] for t in targets}
deadline=time.monotonic()+15
while pending and time.monotonic()<deadline:
try:
request=urllib.request.Request(controller['ready'].rstrip('/')+'/operator/state',data=b'{}',headers={'Authorization':'Bearer '+controller['env']['ARCADE_OPERATOR_TOKEN']})
with urllib.request.urlopen(request,timeout=2) as response: state=json.load(response)
for cabinet in list(pending):
if state.get('cabinets',{}).get(cabinet,{}).get('online'):
print(cabinet+': ONLINE in room controller',flush=True)
pending.remove(cabinet)
except (OSError,ValueError): pass
if pending: time.sleep(.5)
for cabinet in sorted(pending):
print(cabinet+': OFFLINE - check SSH, Pi desktop login, service logs, and cabinet URL/token. Controller remains running.',flush=True)
-18
View File
@@ -1,18 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token =
cabinet_tokens = {}
[cabinet]
executable = ../../ArcadeEscape.exe
id = cabinet_01
; In all mode, blank URL/token use the local controller and this cabinet's token.
url =
token =
gpio = false
+36 -1
View File
@@ -40,7 +40,7 @@ If this fails, fix the reported GPIO permissions/package error or check the resi
## 4. Start the GPIO helper
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
Copy **pi_gpio_bridge.py** and **device_feedback.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
@@ -81,3 +81,38 @@ Keep the helper running while restarting games with Escape. For unattended boot,
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
## PCB live feedback
This optional integration requires the updated controller, launcher, bridge plus `device_feedback.py`, and a new Godot cabinet export (`arcade-cabinet-2.2`). No installation is performed by editing the PCB diagram. Odin remains separate.
1. Save each cabinet's PCB connections in the Godot editor. Give every device a unique ID. For buttons/sensors, check **Input pull-up** when the switch activates by connecting the signal to GND; verify the actual circuit first.
2. Export the catalog from the project root:
```sh
godot --headless --path . --script res://tools/room_controller/export_catalog.gd
```
3. Extract that cabinet's inventory (the destination must not already exist):
```sh
python3 tools/pi_gpio/device_feedback.py --catalog tools/room_controller/catalog.json --cabinet cabinet_01 --output devices.json
```
4. Copy the inventory and updated software to that cabinet Pi. For RFID, enable SPI using Raspberry Pi configuration and build the separate helper on a Pi with a Rust toolchain:
```sh
cargo build --release --manifest-path tools/pi_gpio/rfid_helper/Cargo.toml
```
The binary is `tools/pi_gpio/rfid_helper/target/release/arcade-rfid`. The source follows Odin's MFRC522 library and selector sequence, but this new helper has **not yet been compiled or tested on the tester Pi**. It requires access to GPIO and SPI. Do not run Odin's reader service simultaneously.
5. Add these optional settings to the existing launcher's `[gpio]` section, with paths relative to the launcher INI (or absolute paths):
```ini
devices = /home/YOUR_USER/arcade/devices.json
rfid_helper = /home/YOUR_USER/arcade/tools/pi_gpio/rfid_helper/target/release/arcade-rfid
pins =
```
An empty `pins` list is valid for input/RFID-only testing. If outputs are configured, list only their actual BCM pins and keep the corresponding HardwareActions aligned. Omit `rfid_helper` if no RFID readers are configured. Keep the existing real-GPIO launcher setting enabled.
**Pin ownership:** RFID uses BCM17/27/22 for selection, BCM20 for reset, and SPI0 (BCM8/9/10/11). These cannot also be general outputs. The earlier standalone LED example on BCM17 and start-light example on BCM20 must not be used with this RFID setup. Inputs cannot also be in the output allowlist. The bridge rejects these conflicts before starting.
The operator's cabinet detail and Hardware views display per-device readings. Activity records changes. Disconnected or old samples become stale. J1/J2 are scanned as selector slots 0/1; additional configured readers are scanned in sequence. RFID values currently use Odin's 32-bit UID representation and are not a promise of a complete UID for every card type. A failed tag read is shown as absent **or unreadable**, rather than proof that a card was removed.
Outputs report the GPIO device's logical electrical state after initialization, not proof that a lock or motor moved. Physical confirmation needs a separate sensor. I2C/Other devices are not sampled yet. Feedback is monitoring only; automatic scenario rules for these events are not enabled by this change.
Start with a button and the two readers, then test an appropriately driven output. Confirm real readings and stale/disconnected behavior on the Pi before relying on this for room operation. Windows tests use fake devices; no physical pins were exercised during development.
+114
View File
@@ -0,0 +1,114 @@
"""Read-only feedback inventory; output writes remain in pi_gpio_bridge.Outputs."""
import json
import re
import subprocess
import threading
import time
import uuid
from collections import deque
from pathlib import Path
SIGNALS = dict(zip(['J10','J11','J12','J13','J14','J15','J17','J18','J19','J20','J21','J22'], [5,6,13,19,26,21,16,12,24,23,18,25]))
RESERVED = {17,27,22,20,8,9,10,11}
def load_devices(path, output_pins):
data = json.loads(Path(path).read_text(encoding='utf-8-sig'))
if not isinstance(data, list) or len(data)>32: raise ValueError('Device config must be a list of up to 32 devices')
ids, ports, inputs, readers = set(), set(), set(), False
for d in data:
if not isinstance(d,dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}',d.get('id','')): raise ValueError('Invalid device ID')
if d['id'] in ids or d.get('port') in ports: raise ValueError('Duplicate device ID or port')
ids.add(d['id']); ports.add(d.get('port'))
if not isinstance(d.get('name',''),str) or len(d.get('name',''))>100: raise ValueError('Invalid name')
kind, port = d.get('kind'), d.get('port')
if kind=='rfid':
if port not in ['J'+str(i) for i in range(1,9)]: raise ValueError('RFID requires J1-J8')
readers=True
elif kind in ('input','output'):
if port not in SIGNALS: raise ValueError('Digital devices require a signal connector')
d['pin']=SIGNALS[port]
if kind=='input':
if type(d.get('pull_up')) is not bool: raise ValueError('Inputs require explicit pull_up true/false')
inputs.add(d['pin'])
elif d['pin'] not in output_pins: raise ValueError('Output connector not enabled in output pin allowlist')
else: raise ValueError('Supported kinds: input, output, rfid')
if inputs & set(output_pins): raise ValueError('A GPIO cannot be both an input and an output')
if readers and RESERVED & (set(output_pins)|inputs): raise ValueError('RFID selector/reset/SPI pins cannot be configured as general GPIO')
return data
class Feedback:
def __init__(self, definitions, input_factory, outputs, reader_binary=None, clock=time.monotonic):
self.definitions, self.outputs, self.clock = definitions, outputs, clock
self.lock=threading.RLock(); self.stop=threading.Event(); self.values={}; self.events=deque(maxlen=64)
self.boot=uuid.uuid4().hex; self.sequence=0; self.inputs=[]; self.worker=None; self.reader=None
try:
for d in definitions:
self.values[d['id']] = dict(id=d['id'],name=d.get('name',d['id']),port=d['port'],kind=d['kind'],value=None,error='Awaiting first sample',stamp=clock())
if d['kind']=='input': self.inputs.append((d,input_factory(d['pin'],pull_up=d['pull_up'],bounce_time=.05)))
readers=[d for d in definitions if d['kind']=='rfid']
if readers:
if not reader_binary: raise ValueError('RFID devices require --rfid-helper executable')
self.reader=subprocess.Popen([str(Path(reader_binary).resolve()),','.join(str(int(d['port'][1:])-1) for d in readers)],stdout=subprocess.PIPE,stderr=None,text=True,bufsize=1)
self.worker=threading.Thread(target=self._readers,args=(readers,),daemon=True); self.worker.start()
except Exception:
self.close(); raise
def update(self, ident, value, error=''):
with self.lock:
old=self.values[ident]
changed=old['value']!=value or old['error']!=error
old.update(value=value,error=error,stamp=self.clock())
if changed:
self.sequence+=1
self.events.append(dict(id=self.boot+':'+str(self.sequence),device=ident,value=value,error=error,kind=old['kind']))
def _readers(self, readers):
slots={int(d['port'][1:])-1:d['id'] for d in readers}
try:
for line in self.reader.stdout:
if self.stop.is_set(): break
if len(line)>2048: continue
value=json.loads(line)
ident=slots.get(value.get('slot'))
if ident:
self.update(ident,str(value.get('uid',''))[:32],str(value.get('error',''))[:120])
except Exception as exc:
for ident in slots.values(): self.update(ident,None,'Reader helper error: '+str(exc)[:80])
finally:
if not self.stop.is_set():
for ident in slots.values(): self.update(ident,None,'Reader helper stopped')
def tick(self):
for d,device in self.inputs:
try: self.update(d['id'],bool(device.value))
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
with self.outputs.lock:
for d in self.definitions:
if d['kind']!='output': continue
entry=self.outputs.pins.get(d['pin'])
try: self.update(d['id'],bool(entry['device'].value) if entry else None,'' if entry else 'Output not initialized')
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
def snapshot(self):
with self.lock:
now=self.clock()
return dict(devices=[{k:v for k,v in d.items() if k!='stamp'}|{'age_ms':int(max(0,now-d['stamp'])*1000)} for d in self.values.values()],events=list(self.events))
def close(self):
self.stop.set()
if self.reader:
self.reader.terminate()
try:self.reader.wait(timeout=2)
except subprocess.TimeoutExpired:self.reader.kill();self.reader.wait()
if self.worker:self.worker.join(timeout=2)
self.reader.stdout.close()
for _,device in self.inputs: device.close()
if __name__ == '__main__':
import argparse
parser=argparse.ArgumentParser(description='Extract saved PCB feedback inventory from the exported room catalog')
parser.add_argument('--catalog',required=True)
parser.add_argument('--cabinet',required=True)
parser.add_argument('--output',required=True)
args=parser.parse_args()
catalog=json.loads(Path(args.catalog).read_text(encoding='utf-8-sig'))
devices=catalog['cabinets'][args.cabinet].get('devices',[])
with Path(args.output).open('x',encoding='utf-8') as file:
json.dump(devices,file,indent=2)
print('Wrote',len(devices),'device definitions. Review input polarity and output allowlist before running the bridge.')
+13 -6
View File
@@ -103,7 +103,7 @@ class Outputs:
self.ledger.close()
def handler_for(outputs):
def handler_for(outputs, feedback=None):
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
@@ -115,7 +115,7 @@ def handler_for(outputs):
data = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/heartbeat":
outputs.heartbeat()
result = {"ok": True}
result = {"ok": True, "feedback": feedback.snapshot() if feedback else {"devices": [], "events": []}}
elif self.path == "/output":
result = outputs.command(data)
print("GPIO:", data, flush=True)
@@ -142,14 +142,19 @@ def main():
parser.add_argument("--controller", default="cabinet_01_gpio")
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
parser.add_argument("--devices", help="Explicit PCB feedback JSON inventory")
parser.add_argument("--rfid-helper", help="Built arcade-rfid helper executable")
args = parser.parse_args()
pins = [int(n) for n in args.pins.split(",")]
if not pins or any(pin < 0 or pin > 27 for pin in pins):
pins = [int(n) for n in args.pins.split(",") if n.strip()]
if len(set(pins)) != len(pins) or any(pin < 0 or pin > 27 for pin in pins):
parser.error("Use BCM header GPIO numbers 0..27")
from gpiozero import OutputDevice
from gpiozero import OutputDevice, Button
from device_feedback import Feedback, load_devices
definitions = load_devices(args.devices, pins) if args.devices else []
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
feedback = Feedback(definitions, Button, outputs, args.rfid_helper)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs, feedback))
server.timeout = .05
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
@@ -158,6 +163,7 @@ def main():
def watchdog():
while not stop.wait(.02):
outputs.tick()
feedback.tick()
worker = threading.Thread(target=watchdog, daemon=True)
worker.start()
@@ -168,6 +174,7 @@ def main():
finally:
stop.set()
worker.join()
feedback.close()
outputs.close()
server.server_close()
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "arcade-rfid"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
rppal = "0.22.1"
rppal_mfrc522 = "0.4.1"
serde_json = "1"
+43
View File
@@ -0,0 +1,43 @@
// Uses the same MFRC522 library and selector sequence as Odin; no Odin room logic.
use rppal::{gpio::Gpio, spi::{Bus, Mode, SlaveSelect, Spi}};
use rppal_mfrc522::Mfrc522;
use std::{thread, time::Duration, io::{self, Write}};
fn main() -> anyhow::Result<()> {
let arg = std::env::args().nth(1).unwrap_or_else(|| "0,1".into());
let slots: Vec<usize> = arg.split(',').map(str::parse).collect::<Result<_,_>>()?;
anyhow::ensure!(!slots.is_empty() && slots.len() <= 8 && slots.iter().all(|x| *x < 8), "Slots must be 0..7");
let gpio = Gpio::new()?;
let mut a = gpio.get(17)?.into_output_low();
let mut b = gpio.get(27)?.into_output_low();
let mut c = gpio.get(22)?.into_output_low();
let mut reset = gpio.get(20)?.into_output_low();
thread::sleep(Duration::from_millis(100));
let mut spi = Spi::new(Bus::Spi0, SlaveSelect::Ss0, 1_000_000, Mode::Mode0)?;
let mut reader = Mfrc522::new(&mut spi);
loop {
for slot in &slots {
reset.set_low();
if slot & 1 != 0 {a.set_high()} else {a.set_low()}
if slot & 2 != 0 {b.set_high()} else {b.set_low()}
if slot & 4 != 0 {c.set_high()} else {c.set_low()}
reset.set_high();
thread::sleep(Duration::from_millis(50));
let sample = (|| -> anyhow::Result<serde_json::Value> {
reader.reset()?;
thread::sleep(Duration::from_millis(100));
let version = reader.version()?;
anyhow::ensure!(version != 0 && version != 255, "Reader did not respond");
Ok(match reader.uid(Duration::from_millis(250)) {
Ok(uid) => serde_json::json!({"slot":slot,"uid":format!("{:08X}",uid.to_u32()),"error":""}),
Err(_) => serde_json::json!({"slot":slot,"uid":"","error":"No tag read (absent or unreadable)"}),
})
})();
reset.set_low();
thread::sleep(Duration::from_millis(50));
let value = sample.unwrap_or_else(|e| serde_json::json!({"slot":slot,"uid":"","error":e.to_string()}));
println!("{}",value);
io::stdout().flush()?;
}
}
}
+37
View File
@@ -165,3 +165,40 @@ Automated two-client check (no GPIO; wins forced only by the test harness):
```sh
python3 tests/room_network/check_chain_integration.py /path/to/godot
```
## Three-cabinet objective test
See the [three-cabinet walkthrough](../../docs/three_cabinet_test.md) for the separate Pac-Man five-second auto-win → Galaga 500-point objective → Frogger one-home scenario. This scenario requires updated `arcade-cabinet-2.1` game exports on all three cabinets.
## Escape Gallery browser hub
The browser now opens an **Escape Gallery** overview, with **Glitch: the Dark Machine** as the current room. Navigation provides Room control, Cabinets, Hardware, and Activity. Update the controller Python files and `operator.html`, restart the controller, and reload the browser. These controls use the existing cabinet protocol; this UI update alone does not require a new Godot export.
Room control uses the boundary, walls, device references and cabinet positions exported in `catalog.json`. Cabinets without a position still appear in the cabinet list. Selecting a cabinet opens an original generic cabinet illustration: its marquee follows the assigned game and its screen shows reported phase. It is not gameplay video. Custom cabinet artwork and game-specific screen/marquee assets can replace this illustration later.
Cabinet state refreshes approximately every 1.5 seconds. Device markers are reference locations until sensor integration is implemented. Output rings show pending or failed/unconfirmed commands, not physical device activation. Per-cabinet RFID, digital input, and electrical output feedback is available through the optional [PCB feedback bridge](../pi_gpio/README.md#pcb-live-feedback). Cabinet and Hardware views show readings, errors, and stale status; Activity records changes. The Pac-Man cabinet uses assigned-game artwork, not a live video feed.
Hardware offers only each cabinet's configured actions. An operator reviews the action and supplies a reason. The controller requires real GPIO mode, an online prepared cabinet, the current session, and no pending commands. Requests are deduplicated and reasons appear in Activity. An accepted acknowledgement still does not establish physical delivery. A timeout is not retried automatically. Tokens remain in browser memory and Lock clears access on that device.
Optional hub branding and links to other rooms/services can be set in a separate JSON file:
```json
{
"name": "Escape Gallery",
"room_name": "Glitch: the Dark Machine",
"services": [
{
"id": "trivia",
"name": "Trivia",
"kind": "trivia",
"url": "http://TRIVIA_HOST:8080",
"description": "Open the trivia operator application"
}
]
}
```
Replace the example host before using it, or keep `services` empty. Entries are links, not aggregated live status or shared authentication. Allowed kinds are `room`, `trivia`, and `service`. URLs cannot contain credentials or token query strings.
Pass `--hub-config /path/to/hub.json` to `server.py`, or add `hub_config = /path/to/hub.json` under `[controller]` in the launcher INI. Restart after changes. Keep this optional setting when regenerating deployment configurations. Current authentication remains the shared operator token; individual staff identities are not implemented.
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqb2ndoni3hqb"
path="res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/room_controller/art/pacman_marquee.png"
dest_files=["res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b5u7udnl0fhnw"
path="res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/room_controller/art/pacman_screen.png"
dest_files=["res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+218 -49
View File
@@ -2,59 +2,162 @@
"cabinets": {
"cabinet_01": {
"actions": [
"coin_release",
"start_light"
"coin_slot_1",
"lock_released"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal",
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
"select_button"
],
"name": "Master Cabinet",
"devices": [
{
"id": "cab1_btn_red",
"kind": "input",
"name": "Red Button",
"port": "J17",
"pull_up": true
},
{
"id": "cab1_btn_grn",
"kind": "input",
"name": "Green Button",
"port": "J18",
"pull_up": true
},
{
"id": "cab1_btn_blue",
"kind": "input",
"name": "Blue Button",
"port": "J19",
"pull_up": true
},
{
"id": "cab1_btn_ylw",
"kind": "input",
"name": "Yellow Btn",
"port": "J20",
"pull_up": true
},
{
"id": "cab1_rfid2",
"kind": "rfid",
"name": "RFID Reader 2",
"port": "J2",
"pull_up": true
},
{
"id": "cab1_rfid1",
"kind": "rfid",
"name": "RFID Reader 1",
"port": "J1",
"pull_up": true
}
],
"name": "Cabinet 01",
"position": [
-5.94849967956543,
0.147500038146973
-6.55779981613159,
1.98219990730286
]
},
"cabinet_02": {
"actions": [
"coin_release",
"start_light",
"glitch_code"
"lock_released"
],
"capabilities": [
"joystick_4way",
"button_1",
"joystick_horizontal",
"start_button",
"joystick_horizontal"
"select_button",
"button_1"
],
"devices": [
{
"id": "cab1_btn_red",
"kind": "input",
"name": "Red Button",
"port": "J17",
"pull_up": true
},
{
"id": "cab1_btn_grn",
"kind": "input",
"name": "Green Button",
"port": "J18",
"pull_up": true
},
{
"id": "cab1_btn_blue",
"kind": "input",
"name": "Blue Button",
"port": "J19",
"pull_up": true
},
{
"id": "cab1_btn_ylw",
"kind": "input",
"name": "Yellow Btn",
"port": "J20",
"pull_up": true
},
{
"id": "cab1_rfid2",
"kind": "rfid",
"name": "RFID Reader 2",
"port": "J2",
"pull_up": true
},
{
"id": "cab1_rfid1",
"kind": "rfid",
"name": "RFID Reader 1",
"port": "J1",
"pull_up": true
}
],
"name": "Cabinet 02",
"position": [
-3.51539182662964,
0.142745211720467
-2.89779996871948,
1.98219990730286
]
},
"cabinet_03": {
"actions": [
"coin_release"
"coin_slot_1",
"lock_released"
],
"capabilities": [
"spinner",
"button_1",
"start_button",
"select_button"
],
"devices": [],
"name": "Cabinet 03",
"position": [
-2.89779996871948,
3.20219993591309
]
},
"cabinet_04": {
"actions": [
"coin_slot_1",
"lock_released"
],
"capabilities": [
"roller",
"button_1",
"button_2",
"button_3",
"select_button",
"start_button"
],
"name": "Cabinet 03",
"position": null
"devices": [],
"name": "Cabinet 4",
"position": [
-6.55779981613159,
3.20219993591309
]
}
},
"games": {
@@ -252,6 +355,7 @@
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {},
"mappings": {},
"variant": "standard"
},
@@ -259,43 +363,106 @@
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {},
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1 -> Cabinet 2"
},
"galaga_proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "galaga",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Galaga Proof of Concept"
},
"proof_of_concept": {
"eg_glitch": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_won": [
"coin_release"
]
},
"game_config": {},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_03",
"game": "tempest",
"game_config": {},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_04",
"game": "missile_command",
"game_config": {},
"mappings": {},
"variant": "standard"
}
],
"name": "Proof of Concept"
"name": "Glitch: The Dark Machine"
},
"pacman_rfid_credit_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {
"boards_to_win": 1,
"rfid_allowed_uids": [],
"rfid_credit_device": "cab1_rfid1",
"score_to_win": 0
},
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1: Pac-Man RFID credit"
},
"three_cabinet_objective_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {
"boards_to_win": 0,
"score_to_win": 0,
"test_auto_win_seconds": 5.0
},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {
"score_to_win": 500,
"stages_to_win": 0
},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_02",
"cabinet": "cabinet_03",
"game": "frogger",
"game_config": {
"boards_to_win": 0,
"homes_to_win": 1
},
"mappings": {},
"variant": "standard"
}
],
"name": "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
},
"two_cabinet_test": {
"assignments": [
@@ -303,6 +470,7 @@
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {},
"mappings": {
"game_started": [
"start_light"
@@ -317,6 +485,7 @@
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {},
"mappings": {
"game_activated": [
"glitch_code"
+2 -1
View File
@@ -14,6 +14,7 @@ func run() -> void:
for action in cabinet.hardware_config.actions: actions.append(str(action.action_id))
var placement := room.get_cabinet_placement(cabinet.cabinet_id)
catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null}
catalog.cabinets[str(cabinet.cabinet_id)]["devices"] = cabinet.pcb_config.feedback_devices() if cabinet.pcb_config else []
for scenario in database.scenarios:
var assignments: Array = []
for assignment in scenario.assignments:
@@ -22,7 +23,7 @@ func run() -> void:
for mapping in assignment.event_mappings:
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions))
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "mappings": mappings})
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "game_config": assignment.game_config, "mappings": mappings})
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments}
for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y])
for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
+44 -26
View File
@@ -1,28 +1,46 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Escape Gallery · Operations</title>
<style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}}
</style></head><body>
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header>
<main><div id="error" class="panel" role="alert"></div>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form>
<div id="workspace" hidden>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div>
</div></main><script>
'use strict';
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false;
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
:root{font:16px/1.5 system-ui,sans-serif;color:#25342e;background:#f3f4ef;--muted:#627168;--line:#d7dfd5;--accent:#21644e}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 16px;min-height:46px;border:1px solid #bdccc0;border-radius:8px;background:white;color:inherit;font-weight:600;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:#edf3eb}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button.danger{color:#a44031}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #d29932;outline-offset:3px}input,select,textarea{padding:11px;border:1px solid #b8c7ba;border-radius:8px;min-height:46px;background:white;color:inherit;max-width:100%}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(26px,3vw,36px);line-height:1.2;letter-spacing:-1px;margin-bottom:12px}h2{font-size:20px;margin-bottom:14px}h3{font-size:16px;margin-bottom:8px}p{margin-bottom:14px}small,.muted{color:var(--muted)}small{font-size:13px}label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}[hidden]{display:none!important}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.7px;color:var(--muted);text-transform:uppercase;margin-bottom:10px}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.between{justify-content:space-between}.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:24px}.columns{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,235px),1fr));gap:16px}.badge{display:inline-flex;gap:6px;align-items:center;border-radius:25px;padding:4px 9px;font-size:12px;font-weight:700;background:#edf0e9;color:#53634f}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.good{background:#e0f0e3;color:#28623a}.warn{background:#fff0cd;color:#78550e}.bad{background:#fbe8e2;color:#9c3d29}.blue{background:#e4edf9;color:#2c6095}.notice{background:#fff6e1;border:1px solid #dec18b;color:#775115;padding:14px 18px;border-radius:9px;margin-bottom:18px;white-space:pre-line}.notice:empty{display:none}#error{background:#ffede7;border-color:#dcb3a5;color:#8b3727}#toast{background:#e5f1e4;border-color:#b5d1b6;color:#2a5737}#login{max-width:470px;margin:10vh auto}#login input{width:100%;margin-bottom:16px}#login .primary{width:100%}.mark{display:grid;place-items:center;width:42px;height:42px;border-radius:10px;background:#dfb75b;color:#233c32;font-weight:850}#shell{display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;background:#1c322c;color:#e1e9df;padding:28px 18px}.brand{display:flex;gap:10px;align-items:center;margin-bottom:35px}.brand strong{font-size:17px}.brand small{display:block;font-size:10px;letter-spacing:2px;color:#afc4b5}.nav-label{font-size:10px;letter-spacing:1.6px;color:#a6bfac;margin:22px 12px 8px}.nav{width:100%;justify-content:flex-start;background:transparent;border:0;color:#c4d4c4;padding:12px;margin-bottom:4px;text-align:left;font-size:14px}.nav:hover{background:#2a4537}.nav[aria-current=page]{background:#3c5944;color:white}.nav span{color:#d8bb75;width:20px;font-size:18px}.sidebar footer{margin-top:auto;border-top:1px solid #405742;padding-top:20px}.sidebar footer p{font-size:12px;color:#b8c9b5}.sidebar footer button{background:transparent;color:#e2eadb;border-color:#688267;width:100%}.content{min-width:0}.topbar{padding:16px 30px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px;background:#fafbf6}.topbar strong,.topbar small{display:block}.topbar strong{font-size:14px}.topbar button{font-size:13px;padding:8px 12px;min-height:40px}main{max-width:1600px;margin:auto;padding:30px}.heading{margin-bottom:25px}.heading p{color:var(--muted);max-width:750px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}.stat{padding:19px;background:white;border:1px solid var(--line);border-radius:10px}.stat strong{display:block;font-size:30px;line-height:1.2}.stat span{font-size:12px;color:var(--muted)}.experience{border-top:4px solid #c9a75d}.experience h2{font-size:25px;margin-top:16px}.experience p{color:var(--muted)}.experience .button,.experience button{margin-top:12px}.link{border:0;background:none;padding:0;min-height:32px;color:var(--accent);font-size:14px}.attention{display:flex;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid var(--line)}.attention:last-child{border:0}.attention div{flex:1}.attention strong,.attention small{display:block}.card{display:block;padding:18px;text-align:left;border:1px solid var(--line);border-radius:10px;background:white}.card[aria-pressed=true]{border:2px solid var(Line truncated
.cabinet-visual{margin:auto;width:min(100%,270px);padding:14px 16px 22px;background:#252f2c;border:5px solid #17201c;border-radius:10px 10px 5px 5px;color:#eff5e8;box-shadow:7px 10px 0 #00000010}.marquee{border:3px solid #101813;background:#ecce7e;color:#233420;font-weight:850;text-align:center;padding:9px 6px;letter-spacing:1px;text-transform:uppercase;font-size:15px}.screen{margin:15px 0 0;background:#101d17;border:9px solid #414b42;border-radius:5px;aspect-ratio:1.1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:8px;padding:12px}.screen .screen-game{font-size:20px;font-weight:750;color:#e7c965}.screen .screen-state{font-size:12px;letter-spacing:1px;text-transform:uppercase;color:#b8ddad}.screen .screen-detail{font-size:11px;color:#9aafa0}.deck{background:#3b473d;padding:14px 10px;display:flex;justify-content:space-around;align-items:center;transform:perspective(100px) rotateX(12deg);border-bottom:4px solid #18271d}.joystick{width:18px;height:18px;background:#d8ba60;border-radius:50%;box-shadow:0 6px 0 #142318}.buttons{display:flex;gap:6px}.buttons i{height:9px;width:9px;background:#cce0ba;border-radius:50%}.coin-door{width:58px;height:55px;border:3px solid #414d40;border-radius:3px;margin:25px auto 8px;display:grid;place-items:center}.coin-slot{width:20px;height:5px;background:#101a12}.cabinet-caption{text-align:center;font-size:12px;color:var(--muted);margin-top:14px}.visual-panel{background:#f4f6ee;padding:22px;border-radius:9px}
@media(max-width:1050px){#shell{grid-template-columns:188px 1fr}.sidebar{padding:24px 12px}.brand strong{font-size:15px}.columns{grid-template-columns:1fr}main{padding:24px}.topbar{padding:16px 24px}}@media(max-width:680px){#shell{display:block}.sidebar{height:auto;position:static;display:block;padding:16px}.brand{margin-bottom:14px}.sidebar nav{display:flex;overflow-x:auto;gap:5px}.nav-label,.sidebar footer{display:none}.nav{width:auto;white-space:nowrap;margin:0;padding:10px}.nav span{display:none}.topbar{padding:12px 16px}main{padding:22px 16px}.panel{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.activity li{grid-template-columns:70px 1fr;gap:4px 10px}.activity .message{grid-column:2}#map{height:270px}}@media(min-width:681px){#mobileLock{display:none}}
.marquee img{width:100%;height:100%;object-fit:contain;background:white}.marquee:has(img){padding:0;height:82px}.screen{aspect-ratio:224/288;position:relative}.screen-game:has(img){width:100%;height:100%}.screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}.screen:has(img){padding:0}.screen:has(img) .screen-state{position:absolute;bottom:4px;background:#000b;padding:3px 8px}.screen:has(img) .screen-detail{display:none}.cabinet-visual{border-left:12px solid #24392b;border-right:12px solid #24392b;box-shadow:8px 10px 0 #172a251a}.feedback-list{display:grid;gap:10px;margin-top:16px}.feedback-item{border-left:4px solid #89918d;padding:12px;background:#f4f6ee}.feedback-item.active{border-color:#287c58}.feedback-item.stale{opacity:.65}.cabinet-visual[data-game="pacman"]{border-color:#e8bf21;background:#e8bf21}.cabinet-visual[data-game="pacman"] .deck{background:#303334}.cabinet-visual[data-game="pacman"] .coin-door{background:#26292a;border-color:#171c1a}</style></head><body>
<form id="login" class="panel"><div class="row" style="margin-bottom:26px"><div class="mark">EG</div><div><strong>Escape Gallery</strong><div class="eyebrow" style="margin:0">Operations hub</div></div></div><h1>Ready when you are.</h1><p class="muted">Connect to the controller to follow the room and manage the experience.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button type="button" id="connect" class="primary">Connect to operations</button><p id="loginError" class="notice" role="alert" style="margin-top:16px"></p><p class="small muted" style="margin:18px 0 0">Your token stays in this tab’s memory. Lock the hub when you leave this device.</p></form>
<div id="shell" hidden><aside class="sidebar"><div class="brand"><div class="mark">EG</div><div><strong id="siteName">Escape Gallery</strong><small>OPERATIONS</small></div></div><nav aria-label="Main navigation"><div class="nav-label">WORKSPACE</div><button class="nav" data-view="home"><span>◈</span>Hub overview</button><div class="nav-label">CURRENT ROOM</div><button class="nav" data-view="room"><span>▤</span>Room control</button><button class="nav" data-view="cabinets"><span>▣</span>Cabinets</button><button class="nav" data-view="hardware"><span>⌘</span>Hardware</button><button class="nav" data-view="activity"><span>≡</span>Activity</button></nav><footer><p id="sidebarRoom">Glitch: the Dark Machine</p><p>Building network · Operator access</p><button class="lock">Lock this device</button></footer></aside><div class="content"><header class="topbar"><div><small id="breadcrumb">Operations / Overview</small><strong id="roomName">Glitch: the Dark Machine</strong></div><div class="row"><span id="status" class="badge" role="status">Connecting</span><button id="refresh">Refresh</button><button class="lock" id="mobileLock">Lock</button></div></header><main><div id="error" class="notice" role="alert"></div><div id="toast" class="notice" role="status"></div><div id="stale" class="notice" hidden>Connection lost. Showing last received state; commands are disabled.</div>
<section data-page="home"><div class="heading"><div class="eyebrow">Your operations, together</div><h1>Keep the experience moving.</h1><p>Open a room, check what needs attention, and get back to your players.</p></div><div class="row between"><h2>Experiences</h2><span class="badge">Local network</span></div><div id="experiences" class="grid"></div><div class="stats"><div class="stat"><strong id="onlineCount">—</strong><span>Cabinets online</span></div><div class="stat"><strong id="playingCount">—</strong><span>Games in progress</span></div><div class="stat"><strong id="readyCount">—</strong><span>Ready to start</span></div><div class="stat"><strong id="attentionCount">—</strong><span>Need attention</span></div></div><div class="columns"><section class="panel"><div class="row between"><h2>Needs attention</h2><button class="link" data-go="cabinets">View cabinets →</button></div><div id="attention"></div></section><section class="panel"><div class="row between"><h2>Recent activity</h2><button class="link" data-go="activity">View all →</button></div><ol id="recent" class="activity"></ol></section></div></section>
<section data-page="room" hidden><div class="heading"><div class="eyebrow">Game-master workspace</div><h1 id="roomHeading">Glitch: the Dark Machine</h1><p>Follow the room layout and cabinet sequence. Select a station for its live reported state and controls.</p></div><div class="columns"><section class="panel"><h2>Scenario preparation</h2><label for="scenario">Scenario</label><select id="scenario" style="width:100%"></select><ol id="scenarioPlan" class="scenario-plan"></ol><button id="prepareScenario" class="primary" style="width:100%">Prepare assigned cabinets…</button><p class="small muted" style="margin-top:14px">Preparation can run configured activation outputs. Linked cabinets wait for the preceding win; independent cabinets are started individually.</p><div id="scenarioBlockers" class="notice"></div></section><section class="panel"><h2>Room layout</h2><svg id="map" role="group" aria-label="Room layout; select a cabinet for controls"></svg><div class="legend"><span><i style="background:#287c58"></i>Ready / won</span><span><i style="background:#426ca4"></i>Playing</span><span><i style="background:#9a762d"></i>Waiting / idle</span><span><i style="background:#89918d"></i>Offline</span></div><p class="small muted">Device markers are reference locations, not live sensor readings. Cabinet rings indicate pending or unconfirmed output commands.</p></section></div><div class="row between" style="margin:24px 0 14px"><h2 style="margin:0">Cabinet status</h2><button class="link" data-go="cabinets">Open controls →</button></div><div class="grid" id="roomCards"></div></section>
<section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
<section data-page="hardware" hidden><div class="heading"><div class="eyebrow">Physical devices</div><h1>Hardware &amp; outputs</h1><p>Trigger configured actions and review acknowledgements. Accepted does not prove that a lock moved or a reward was delivered.</p></div><section class="panel"><label for="hardwareCabinet">Cabinet</label><select id="hardwareCabinet"></select><p id="hardwareHint" class="small muted" style="margin-top:14px"></p><div class="grid" id="actions"></div><h2 style="margin-top:26px">Command history</h2><div class="table-wrap" id="output"></div><h2 style="margin-top:24px">Live device feedback</h2><div id="feedback" class="feedback-list"></div></section><section class="panel" style="margin-top:20px"><div class="row between"><h2>Room devices</h2><span class="badge">Integration pending</span></div><p class="small muted">Device feedback is listed above when a configured Pi bridge reports it. These room markers remain reference locations until mapped to sensor IDs.</p><div id="roomDevices" class="grid"></div></section></section>
<section data-page="activity" hidden><div class="heading"><div class="eyebrow">Room history</div><h1>Activity</h1><p>Cabinet events, operator interventions, and command acknowledgements in one place.</p></div><section class="panel"><div class="row filters"><label class="visually-hidden" for="activityCabinet">Filter cabinet</label><select id="activityCabinet"></select><label class="visually-hidden" for="activitySearch">Search activity</label><input type="search" id="activitySearch" placeholder="Search events or actions…"><span id="eventCount" class="small muted"></span></div><ol class="activity" id="events"></ol><p class="small muted" style="margin-top:20px">Latest retained history: up to 100 events per cabinet. Operator entries record actions and reasons; individual staff accounts are not configured.</p></section></section>
</main></div></div><dialog id="confirmDialog" aria-labelledby="confirmTitle"><form id="confirmForm"><div class="eyebrow">Review action</div><h2 id="confirmTitle"></h2><p id="confirmText" class="muted"></p><div id="confirmReason" hidden><label for="reason">Reason for this intervention</label><textarea id="reason" rows="3" maxlength="200"></textarea></div><div class="row"><button type="button" id="cancelConfirm">Cancel</button><button type="submit" class="primary" id="acceptConfirm">Confirm</button></div></form></dialog>
<script>
const $=id=>document.getElementById(id), phases={idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
let token='',state=null,selected='',view='home',connected=false,pending=false,refreshing=false,epoch=0,confirmation=null;
const node=(tag,text='',cls='')=>{const n=document.createElement(tag);n.textContent=text;n.className=cls;return n};
const name=id=>state?.cabinets[id]?.definition.name||id, gameName=id=>state?.catalog.games[id]?.name||id||'No game assigned';
const busy=c=>c.commands.some(x=>x.status==='queued'), prep=c=>c.online&&!busy(c)&&['idle','won','lost','needs_prepare'].includes(c.phase);
const hardware=c=>connected&&!pending&&c?.online&&c.gpio==='real'&&c.session&&!busy(c)&&['ready','playing','won','lost'].includes(c.phase);
function options(id,items){const s=$(id),old=s.value,key=JSON.stringify(items);if(s.dataset.options===key)return;s.dataset.options=key;s.replaceChildren(...items.map(([v,t])=>{const o=node('option',t);o.value=v;return o}));if(items.some(x=>x[0]===old))s.value=old}
function cached(id,key,fn){const e=$(id),s=JSON.stringify(key);if(e.dataset.key!==s){e.dataset.key=s;fn(e)}}
function navigate(next){view=['home','room','cabinets','hardware','activity'].includes(next)?next:'home';document.querySelectorAll('[data-page]').forEach(e=>e.hidden=e.dataset.page!==view);document.querySelectorAll('[data-view]').forEach(e=>e.setAttribute('aria-current',e.dataset.view===view?'page':'false'));$('breadcrumb').textContent='Operations / '+({home:'Overview',room:'Room control',cabinets:'Cabinets',hardware:'Hardware',activity:'Activity'}[view]);if(state)render()}
function select(id,next='cabinets'){selected=id;navigate(next)}
async function api(path,data={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),6000);try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:controller.signal});const b=await r.json();if(!r.ok)throw Error(b.error||'Request failed');return b}finally{clearTimeout(timer)}}
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
async function refresh(){if(!token||pending||refreshing)return;refreshing=true;const generation=epoch;try{const s=await api('/operator/state');if(generation!==epoch)return;state=s;connected=true;$('login').hidden=true;$('shell').hidden=false;$('loginError').textContent='';render()}catch(e){if(generation!==epoch)return;connected=false;$('loginError').textContent=e.message;enable()}finally{refreshing=false;$('status').textContent=connected?'Connected':'Disconnected';$('status').className='badge '+(connected?'good':'bad');$('stale').hidden=connected;enable()}}
async function command(action,data){if(!connected||pending)return;pending=true;const generation=++epoch;enable();$('error').textContent='';$('toast').textContent='';try{const result=await api('/operator/command',{action,...data});if(generation!==epoch)return;state=result;render();$('toast').textContent='Command submitted. Follow its acknowledgement in Activity.'}catch(e){if(generation!==epoch)return;$('error').textContent=e.name==='AbortError'?'Reply timed out. Check Activity before repeating the action.':e.message}finally{pending=false;enable();refresh()}}
function review(title,text,fn,reason=false){confirmation=fn;$('confirmTitle').textContent=title;$('confirmText').textContent=text;$('confirmReason').hidden=!reason;$('reason').value='';$('reason').required=reason;$('reason').minLength=reason?3:0;$('confirmDialog').showModal()}
function allEvents(){return Object.entries(state.cabinets).flatMap(([id,c])=>c.history.map((e,i)=>({...e,id,i}))).sort((a,b)=>b.time-a.time||b.i-a.i)}
function events(id,list){cached(id,list,e=>{e.replaceChildren(...list.map(x=>{const li=node('li');li.append(node('time',new Date(x.time*1000).toLocaleTimeString()),node('strong',name(x.id)),node('span',x.message,'message'));return li}));if(!list.length)e.append(node('li','No events yet.','empty'))})}
function cards(id){const root=$(id);for(const [key,c] of Object.entries(state.cabinets)){let b=Array.from(root.children).find(e=>e.dataset.cabinet===key);if(!b){b=node('button','','card');b.dataset.cabinet=key;b.onclick=()=>select(key);root.append(b)}b.setAttribute('aria-pressed',String(key===selected));const signature=JSON.stringify([c.online,c.phase,c.game,c.definition.name]);if(b.dataset.signature!==signature){b.dataset.signature=signature;b.replaceChildren(node('strong',name(key)),node('span',c.online?(phases[c.phase]||c.phase):'Offline','badge '+(c.online?'good':'')),node('p',gameName(c.game),'game'))}}}
function home(){const cs=Object.values(state.cabinets);$('onlineCount').textContent=cs.filter(c=>c.online).length+' / '+cs.length;$('playingCount').textContent=cs.filter(c=>c.online&&c.phase==='playing').length;$('readyCount').textContent=cs.filter(c=>c.online&&c.phase==='ready').length;const attention=Object.entries(state.cabinets).filter(([,c])=>!c.online||c.phase==='needs_prepare'||c.commands.some(x=>['failed','unknown'].includes(x.status)));$('attentionCount').textContent=attention.length;cached('experiences',state.hub,e=>{const b=node('button','','experience');b.append(node('span','ARCADE ROOM','eyebrow'),node('h2',state.hub.room_name),node('p','Room layout, cabinet controls and hardware activity'),node('span','Open room →','link'));b.onclick=()=>navigate('room');e.replaceChildren(b);for(const s of state.hub.services){const a=node('a','','experience');a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';a.append(node('span',s.kind.toUpperCase(),'eyebrow'),node('h2',s.name),node('p',s.description),node('small','External service · status not monitored'));e.append(a)}});cached('attention',attention.map(([id,c])=>[id,c.online,c.phase]),e=>{e.replaceChildren(...attention.map(([id,c])=>{const b=node('button',name(id)+' — '+(!c.online?'Offline':c.phase==='needs_prepare'?'Needs preparation':'Review command history'),'attention');b.onclick=()=>select(id);return b}));if(!attention.length)e.append(node('p','All cabinets are reporting normally.','muted'))});events('recent',allEvents().slice(0,6))}
function scenario(){const s=state.catalog.scenarios[$('scenario').value];$('scenarioPlan').replaceChildren(...(s?.assignments||[]).map(a=>{const li=node('li');li.append(node('strong',name(a.cabinet)+' · '+gameName(a.game)),node('p',a.activate_after_win?'After '+name(a.activate_after_win)+' wins':'Ready for operator start'),node('small',Object.entries(a.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(' · ')));return li}));$('scenarioBlockers').textContent=(s?.assignments||[]).filter(a=>!prep(state.cabinets[a.cabinet])).map(a=>name(a.cabinet)+': must be online, reset, and free of pending commands').join('. ')}
function map(){const layout=state.catalog.layout||{},cs=Object.entries(state.cabinets);cached('map',[layout,cs.map(([id,c])=>[id,c.online,c.phase,c.commands])],svg=>{svg.replaceChildren();const pts=[...(layout.boundary||[]),...(layout.walls||[]).flat(),...(layout.devices||[]).map(d=>d.position),...cs.map(([,c])=>c.definition.position)].filter(Boolean);if(!pts.length)return;const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]),minX=Math.min(...xs)-.7,minY=Math.min(...ys)-.7,w=Math.max(...xs)-minX+.7,h=Math.max(...ys)-minY+.7;svg.setAttribute('viewBox',[minX,minY,w,h].join(' '));const draw=(tag,attrs,parent=svg)=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);parent.append(e);return e};if(layout.boundary?.length)draw('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#edf0e9',stroke:'#b6beb5','stroke-width':.04});for(const wall of layout.walls||[])draw('polyline',{points:wall.map(p=>p.join(',')).join(' '),fill:'none',stroke:'#728176','stroke-width':.07});for(const d of layout.devices||[]){if(!d.position)continue;const e=draw('rect',{x:d.position[0]-.05,y:d.position[1]-.05,width:.1,height:.1,fill:'#555'});draw('title',{},e).textContent=d.name+' — reference location; feedback unavailable'}for(const[id,c]of cs){const p=c.definition.position;if(!p)continue;const g=draw('g',{role:'button',tabindex:0,'aria-label':name(id)+': '+(c.online?c.phase:'offline')});g.style.cursor='pointer';g.onclick=()=>select(id);g.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();select(id)}};draw('circle',{cx:p[0],cy:p[1],r:.17,fill:!c.online?'#89918d':c.phase==='playing'?'#426ca4':['ready','won'].includes(c.phase)?'#287c58':'#9a762d'},g);const output=c.commands.filter(x=>['output','reward'].includes(x.type)).at(-1);if(output&&['queued','unknown','failed'].includes(output.status)){const ring=draw('circle',{cx:p[0],cy:p[1],r:.23,fill:'none',stroke:output.status==='queued'?'#b28934':'#bd4a3e','stroke-width':.035},g);draw('title',{},ring).textContent='Output command '+output.status+'; physical state unverified'}draw('text',{x:p[0],y:p[1]-.3,'text-anchor':'middle','font-size':.16,fill:'#263f32'},g).textContent=name(id)}})}
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','ready','playing','won'].map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
function hardwareView(){renderFeedback();const c=state.cabinets[selected];$('hardwareCabinet').value=selected;$('hardwareHint').textContent=c?name(selected)+' · GPIO: '+c.gpio+' · '+(hardware(c)?'Configured outputs available.':'Requires an online, prepared cabinet with real GPIO and no pending commands.'):'No cabinet selected';cached('actions',[selected,c?.definition.actions],e=>{e.replaceChildren(...(c?.definition.actions||[]).map(action=>{const box=node('div','','panel'),b=node('button','Trigger…');b.dataset.output=action;b.onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Trigger '+action+'?',name(id)+' will run this configured physical action. Check the connected device before proceeding.',reason=>{const bytes=crypto.getRandomValues(new Uint8Array(16));command('trigger_output',{cabinet:id,expected_session:session,action_id:action,reason,request_id:Array.from(bytes,x=>x.toString(16).padStart(2,'0')).join('')})},true)};box.append(node('h3',action),node('p','Configured cabinet action','small muted'),b);return box}));if(!e.children.length)e.append(node('p','No actions configured.','empty'))});cached('output',[selected,c?.commands],e=>{const list=(c?.commands||[]).filter(x=>['reward','output'].includes(x.type)).slice().reverse();e.replaceChildren(...list.map(x=>node('p',(x.action_id||x.type)+' · '+(x.source==='operator'?'Operator':'Scenario / win output')+' · '+x.status+(x.status==='accepted'?' — physical delivery unconfirmed':''))));if(!list.length)e.append(node('p','No hardware commands yet.','muted'))});cached('roomDevices',state.catalog.layout?.devices,e=>e.replaceChildren(...(state.catalog.layout?.devices||[]).map(d=>{const n=node('div','','panel');n.append(node('strong',d.name),node('p',d.id+' · feedback unavailable','small muted'));return n})))}
function activity(){const id=$('activityCabinet').value,q=$('activitySearch').value.toLowerCase();const list=allEvents().filter(e=>(!id||e.id===id)&&(!q||(e.message+' '+name(e.id)).toLowerCase().includes(q)));$('eventCount').textContent=list.length+' events';events('events',list)}
function render(){if(!state?.cabinets)return;if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];const hub=state.hub||{name:'Escape Gallery',room_name:'Glitch: the Dark Machine',services:[]};state.hub=hub;$('siteName').textContent=hub.name;for(const id of ['sidebarRoom','roomName','roomHeading'])$(id).textContent=hub.room_name;document.title=hub.name+' · Operations';options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name||id]));options('hardwareCabinet',Object.keys(state.cabinets).map(id=>[id,name(id)]));options('activityCabinet',[['','All cabinets'],...Object.keys(state.cabinets).map(id=>[id,name(id)])]);if(view==='home')home();if(view==='room'){scenario();map();cards('roomCards')}if(view==='cabinets'){cards('cards');detail()}if(view==='hardware')hardwareView();if(view==='activity')activity();enable()}
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
const PACMAN_ART={"screen": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOAAAAEgCAIAAAAi0Oi2AAAQAElEQVR4AeydTX7kthHFu+cmPYtcxBtvnVvkRL6FvfUmF8lCfRTl31MSVAII4pMgSFZ+z1ChUF94fFZr6JnJj9t5//f29g7c/bCBbDE8iJ819IuHI4G3Fae3SoysOUcS6VZJcVtnxPwEyNH51jMLdOVp/fx5BxKAAcSWJ83WQfxFqy5ComwxgNhS3G21kyPnxwB4BNgSiQHE6VY8p8RFBbr4LOXx88gXT+ucVAOS69XHD+TI1hgD5xcoshDEKFj0SwqrO8UWOE/MENlJsKyxyHw/dbxgPA7e0Zm25xcochEUPTZSvHg8As+/uJVIWQlASax1IBeQ66phA9nKyvasOL9A858cD5tgUQNGNagAwnSvPjEgDPM8koUzJ5iwk6FeoIcmgocN5AoYQGxRA1sgnopVF5E64qGUGDiB22qn9hMgkABsOcUA2Bp4TokzC5TnCtxjwwayxfAgftbQLx6OBN5WnN4qMW7Vp86J4fzYGuIXj7bxsGUNgf+UOLNAT/nArnapzgLVHzrYmk22DtqPLX4MDXGyaudsNuNp6PFifmLkCENDnKzaaXZPgQq57tMHcsXjDH2EU+BiZCsrTglmi806IWQwmZOVCcXjDJwCPA4uxnkwcLpIbDwGYaCnQB3FlNYsi80p/hChX+IlUk61R/wzrMwGZBI9odjuSALcGvolXgLkVHvEf9m1p0CFRMgFYgvdYuP8wNu7eM6xyqXkLle4r9x02NpZoDwtN7p+WjjZCrB1GNvjQl+E2+mLsBXg1GFsDfkM9BSoewzuweTPoSNJZ+uqYYsHYyq4CRkPVM8mua4adcSDYegpUMcmXDvgFLpDD0fixADa1iliEzAtZHJZGVIGli2r82CwBRgAA2AAnSI2TgMM9BQozIagB/D8eASen634WbEF2HNCxvNWGXXRyZHnZ4tTgC2Qra3CQE+BSkVbjYGODBxLoB0vbqWOwYAJ9BjP6bJTmkAv++iPcXET6DGe02WnNIFe9tEf4+Im0GM8p8tO+U2gj8f7WWH3OhAD+t/GD4HK9M/n3WAM7M6AqFFk+hIoe5lJXLYaA/syIGpElozxEihfHPAaihhw1C0aRaUsGAY8Gn/gQrDiFZutIZ8BSBP2wpWj/DoWKQxAmjDJFvvrOygbXHJmaz4DkAZ1YTxOjkK/edYZgDSoczFfAnUuM74xYJtdGTCB7kq/NU8xYAJNMWTnuzJgAt2VfmueYsAEmmLIzndlIC3Q93/+AuGQOMFx/eHkYzyQBsJeOIH5PQbSAvUSbJvNgAV2YCAt0Pvv/wZhK5zguP5w8jEeSANhL5zA/B4DaYF6CbY1BkYyYAIdybb1KmbABFpMmSWMZMAEOpJt61XMgAm0mLIBCdbCMZAWKC/ngEsYZtAUDGs3rBGXAsPaJRsxDEiGdQ+gKUiWTQs0WeLR8CeZksVXAlr6xnJX2g07is3W4m8Zfq++MnNaoLycAxIdrkz/bPiTTKSHNcVDUyB2uJLY0jeWS9mwV18PlwKxmgwQm63FT9lYR4YBsVMSd+nr5kkL1IWGhkwf+vM9XJ4i+fESSQqJYvddKUvxvjXzq9GaAfLj8yMpS/H8eIkkhUSx61bSKVKXK1lNApUSthoD2zGQECg/xjpsN8TpK7+/34B3TTzAc7ZuD5LvRIWxPvKaQL1kb7te104dA+sqXD91Rc5keELytt5NowJdTFt0ehVtG2NAa1HbsfhT+hcltOiU60cFKsc7rgwNdhygS+scIebEdBlmsQgkg8WjGZzzCnQGdmyG3RmYV6C8nAO7E9R3APlmKWvfytXVIBlUp2+dGBXo4tCLzq1HtPpDGBjUZFFCi04ZKCpQjr00b0uAoYKBqb59VszfnuIJydt69dcESijJDmwNpQzc7+mMnJh0lUNFOFFhrA+eEOh6sp0aA1sz0CTQ9v/Syn+opUjpJUkhsTQrJ56yFM+J1DGkkKg9bHGKZ/0bpD4lhUTJ6rtSluKlNUkhsTRLx5NOEe0ptdMC5SUZiNWlPUNUg/RYZZqC2CmJ1U1XEikb67juJ1GXZavjUSHQHmw8AEODRF2nl01Z3UXbkAy0R9sktsxAuq6mbZoC7Vm00wJdTNNOhqiGrlNqVzddSSydQcfrstrvbOSo4fyeoev0sr0WRdvkDCsBRY0Wg9MC5cdYsJi8qZOmoFsLfvH8iffbzaFb/Xih93/eHeJRu51AMhjfnqYg2Tct0GSJ2QNEl7+mfL/fwS/zYxGlfmx6f5EPR1019OhTs0MGzi5Q1Pl5aU+an+7XV2T6+tL1n7e3j6rPf33UdYY7+jiwL3EGTi3QT3W+PW9Onc+3d6AJYQs+1KQPGmw+08n+739vAMOBLWArARiGdQZOLdBfV0edv76+FoT4+nK7rRgSYOskDJxfoCHRv/12A9rPFmhPu/3430eNFeMj4rJfMi6eFigvq0BYCieY3x9OuO7hUmA9JvMUaQIdzBZoz7Q2JIBwPJxgO79XOS1QL+Ew288fQH8+bo+fH/9FXAx+Cnx7Lnh6/Ria+fNlZthhCN9m0LRAeVkFwu44wfx+b0I0CrSTLXAeLgXc9rIGJIDw+jjBdn6vclqgXoJtjYGRDJhAR7JtvYoZMIEWU2YJIxmICHTkCGN73T9/8SRtva04O6733z9+NSY1va04bV1h4LwCvX8q437/tG4vOd7v0e0KTyVHToViyEoBMWR1WwzDCgNpgfLSC6yU2OiIpqCpOFK83ykCECW44flVERv4219HXRZUCFwpbBDbOn+RwaVAUcpUwQwPkiOlBZosIb9Dp25NFl8J0B1XwpJHus5sdnL4lQB9l5Ww5JGuU2oniycD0gLlpReIFWLild+vmjwiPVaZpiB2SqIuzjYWSREQOyVR15nNZrzY5FwKxE5J1HdhG4tc95Oo65TapMfqMzyInTp/WqAuNDRoz8ShP99DOkXy4yWSFBLFlpUtTrHzV1JIzI8fH8l4DFnalxQSdRZbnNqTY5NCYk5kLIZ0isROc/xNAl1qYD5joCcDJtCebFqt7gyYQLtTagV7MtAk0PafMPgBhSI9L1RSi9YMUJIxOpbxGHJ0189+tGaAz13NV9IpUpP5mZMWKC+rwGe8/5X2DFEN0v2Kn3uags9d/VeKgFg+A1QPL4mxyuKXmOqV8aROuHIpEPr7ehigengSSY/Nw/Agdur8aYG60JjBENWI1Rzprx5eEnkMsWk5kpjqNVZ5pL96eBLb50wLlJdVoL1TsoIXQFPgOSu2FAHrie///OGwHhme8hgQYujHyVHo7+XhUqBXtfF1GB4k+6YFmixx6ADRpb5C6NGnZg9m4NICRYsxuleOYinm34KB6wrUSfD5r7+AJpctcAH6yOzBDFxXoEI0QkwaEmDrLgxcXaBC+m9D/qS89LK1iIG0QHlZBcKiOMF4f9jxtupiSLAaknVIEZAVuhpEERCG4ATt/rBCnYdhQJiLE2zn9yqnBeolnGPrfr58/O/f3Oj1J+X/fBnY2uPC8Bt2YSAtUF5WgXA4nGC8P+y47mFIsBKDIoEOYAu0B5siAKMRFAFhEZyg3R9WqPMwDAhzcYLt/F7ltEC9BNsaAyMZMIGOZNt6FTNgAi2mzBJGMnBxgb6ovv/+9+vL5z/e9tNtX/dh4KICdSoUQ1aegBiyui2GYS8GvgT6fN4fj15/A2GH6/CyDeQXKp0fFQJXHxvEts4/v8FDhIr554xN6M3/g8vgkmix2Q6D9O21ts/fa5KWOo3kQ0JL9zC3cZ7SdDc/idhf30FlMlwjwRDSN1x52QZC/7qncfiVedb79jplgMYr9JpE6rTPU3od6evWl0ApwRzAeYcZ0npYu2SjfefhETBAcshhAXvNQ18gVLwEyoXZALzDQNND42rDDxMGjZAiEIY/BCobvMPAHNLU1vkZ4GENEwaNNCHfBKoPzDYGZmDABPrtKci3im+ujA3/0pOoA9ni1J4cmxQScyKvEzOvQHkJCkqfBA+4BUiktKPEk6j7shV/6UqirlNhl3acPH5egVYQx+PkAbegoqlL0X2ds8LQdSpsSKhoOm3KvALlJSjIJ44Hw+PMj98tcuPGkAAVGzcZV35egY7jwDpNzIAJdOKHY6PdbiZQU8HUDJhAp348NpwJ1DQwNQPzCpSXoGBq8kYPd8V+8wr0ik/D7hwwMK9AeQkKgoHNcS0G5hXotZ6D3TbCgAk0Qoy552DABDrHc7ApIgyYQCPEnMt93NuYQI/77C4x+bwC5SUouMRDsEvGGZhXoPGZu508Hu/VKB2iuhGJpb3OFD+vQHkJCrbm+vm8V4Cp+O5eBFIqGpFC4pUxr0Cv/FTs7o4BE+iLCj5Gi0AO392LQEpRC4JJ2R97T/ADInbBbB9ezFOE0gdXVJzg0vqbxjPPLiKh6esvD6P9eGxKqBXvzsB4hUjHnT/i+VeEOUrZJIVEncUWp/Zc2YYKCNEMsMWpPTk2KSTmRG4Xs9tHPDcHUBC7m/waOXZKIukObGOR1/RDiCMHg20dDySSviN2+4jn5qCONcki3UE8tmoGHDkY2l9qk74jdv6IXyFLfo28EmBHczPQZ7p5Bdrnflbl4AyYQA/+AM8+vgn07E/44PczgR78AZ59fBPo2Z/wwe83r0DX34MenHYbf40BfTavQPWUZl+WgXkFau9BLytKffF5BaqnNPuyDJhAL/voj3FxE+gxntNlpzSBXvbRH+PiZQI9xp1syhMxMK9A7T3oiWRWf5Wdf8Ny/eC3m/5dtC11zprbix9dZ7y9829Y5sIxfay/ByVR/y5atrE61/RDSBd+vDq65hh75494LgkFpRoihUSdxRan9lzZhgoI0Qywxak9OTYpJOZEbhez20f8dleyylswgFh3waiP+OBvmOG2W/BYXZN5pkL1RbZIhBm+le6CnT/it2CzLine truncated
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost'].includes(c.phase)?phases[c.phase]:'Waiting for operator'});}
function renderFeedback(target='feedback'){const c=state.cabinets[selected];const f=c?.feedback;cached(target,[selected,f,c?.online],e=>{e.replaceChildren();if(!f?.devices?.length){e.append(node('p',f?.error||'No configured device feedback received.','muted'));return}for(const d of f.devices){const box=node('div','','feedback-item'+(d.value===true?' active':'')+(!c.online||!f.fresh||d.age_ms>6000?' stale':''));box.append(node('strong',d.name||d.id),node('p',(d.port||'')+' · '+d.kind+' · '+(!c.online||!f.fresh||d.age_ms>6000?'STALE / unavailable':d.error||(typeof d.value==='boolean'?(d.value?'Active':'Inactive'):String(d.value))),'small'));if(d.kind==='output')box.append(node('small','Electrical output state; physical movement is unconfirmed.'));e.append(box)}})}
</script></body></html>
+170 -8
View File
@@ -3,7 +3,9 @@
import argparse
import hmac
import json
import math
import os
import re
from pathlib import Path
import sqlite3
import time
@@ -12,6 +14,42 @@ from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, HTTPServer
def hub_config(path=None):
default = dict(name="Escape Gallery", room_name="Glitch: the Dark Machine", services=[])
if path is None:
return default
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Hub configuration must be an object")
for key in ("name", "room_name"):
value = data.get(key, default[key])
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 80:
raise ValueError("Invalid hub " + key)
default[key] = value.strip()
entries = data.get("services", [])
if not isinstance(entries, list) or len(entries) > 40:
raise ValueError("Hub services must be a list of up to 40 entries")
seen = set()
for entry in entries:
if not isinstance(entry, dict): raise ValueError("Invalid hub service")
ident, name, kind, url = (entry.get(k, "") for k in ("id", "name", "kind", "url"))
if not isinstance(ident, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", ident) or ident in seen:
raise ValueError("Invalid or duplicate hub service ID")
seen.add(ident)
if not isinstance(name, str) or not 1 <= len(name) <= 80 or kind not in ("room", "trivia", "service"):
raise ValueError("Invalid hub service name or kind")
if not isinstance(url, str): raise ValueError("Invalid hub service URL")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query or any(c.isspace() or ord(c) < 32 for c in url) or "\\" in url:
raise ValueError("Use an HTTP(S) service URL without credentials, query or fragment")
try: parsed.port
except ValueError: raise ValueError("Invalid hub service port")
description = entry.get("description", "")
if not isinstance(description, str) or len(description) > 240: raise ValueError("Invalid service description")
default["services"].append(dict(id=ident, name=name, kind=kind, url=url, description=description))
return default
class Room:
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
self.clock = clock
@@ -25,9 +63,13 @@ class Room:
self.state = json.loads(row[0]) if row else dict(
session="", phase="idle", reward="not requested", commands=[], seen=[], history=[])
# Never carry live connectivity across a controller restart.
self.feedback = {}
self.feedback_at = 0
self.feedback_seen = []
self.last_seen = 0
self.boot = self.state.get("boot", "")
self.version = "unknown"
self.features = []
self.gpio = "unknown"
self.state.setdefault("game", "pacman")
self.state.setdefault("mappings", {"game_won": ["coin_release"]})
@@ -47,8 +89,42 @@ class Room:
def snapshot(self):
self.expire()
return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
version=self.version, gpio=self.gpio,
last_seen=self.last_seen, protocol=2)
version=self.version, gpio=self.gpio, features=self.features,
last_seen=self.last_seen, protocol=2, feedback=self.feedback_snapshot())
def feedback_snapshot(self):
result = dict(self.feedback)
age = max(0, self.clock() - self.feedback_at)
result['fresh'] = bool(result.get('fresh') and self.online() and age < 4)
result['devices'] = [dict(d, age_ms=d['age_ms'] + int(age*1000)) for d in result.get('devices', [])]
return result
def receive_feedback(self, payload):
if not isinstance(payload, dict): return
devices, events = payload.get('devices', []), payload.get('events', [])
if not isinstance(devices, list) or len(devices) > 32 or not isinstance(events, list) or len(events) > 64: return
clean = []
for d in devices:
if not isinstance(d, dict) or d.get('kind') not in ('input', 'output', 'rfid'): continue
ident = d.get('id')
if not isinstance(ident, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', ident): continue
value = d.get('value')
if value is not None and type(value) is not bool and not isinstance(value, str): continue
age = d.get('age_ms', 999999)
if type(age) not in (int, float) or not math.isfinite(age) or age < 0: continue
clean.append(dict(id=ident, name=str(d.get('name',ident))[:100], port=str(d.get('port',''))[:8], kind=d['kind'], value=value[:64] if isinstance(value,str) else value, error=str(d.get('error',''))[:120], age_ms=min(int(age),999999)))
fresh = payload.get('fresh') is True and self.gpio == 'real'
self.feedback = dict(devices=clean, fresh=fresh)
self.feedback_at = self.clock()
if not fresh: return
known = {d['id'] for d in clean}
for event in events:
if not isinstance(event, dict): continue
ident = event.get('id')
if not isinstance(ident,str) or len(ident)>100 or ident in self.feedback_seen or event.get('device') not in known: continue
self.feedback_seen.append(ident)
self.feedback_seen = self.feedback_seen[-256:]
self.log('Device '+event['device']+': '+str(event.get('error') or event.get('value'))[:120])
def expire(self):
for command in self.state["commands"]:
@@ -65,7 +141,7 @@ class Room:
command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind,
status="queued", expires=self.clock() + 15)
if kind == "prepare":
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"))
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"), game_config=self.state.get("game_config", {}))
self.state["commands"].append(command)
self.log("Queued " + kind)
return command
@@ -82,6 +158,8 @@ class Room:
reward="not requested", commands=[], seen=[])
self.command(action)
elif action == "start":
if self.state.get("game_config", {}).get("rfid_credit_device"):
raise ValueError("Scan a card and press Start on the cabinet for this RFID scenario")
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
self.state["phase"] = "starting"
self.command("start")
@@ -103,7 +181,9 @@ class Room:
self.state["boot"] = boot
self.last_seen = self.clock()
self.version = str(data.get("version", "unknown"))[:80]
self.features = data.get("features", []) if isinstance(data.get("features", []), list) else []
self.gpio = str(data.get("gpio", "unknown"))[:100]
self.receive_feedback(data.get("feedback", {}))
self.expire()
reports = data.get("reports", [])
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
@@ -170,7 +250,8 @@ class Room:
class Fleet:
"""Editor catalog plus independently acknowledged cabinet sessions."""
def __init__(self, database, catalog, clock=time.time):
def __init__(self, database, catalog, clock=time.time, hub=None):
self.hub = hub if hub is not None else hub_config()
self.catalog = catalog
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
@@ -187,7 +268,7 @@ class Fleet:
compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)])
# Retain the single-cabinet desktop UI response while the browser offers the fleet view.
first = cabinets.get("cabinet_01", next(iter(cabinets.values())))
return dict(first, cabinets=cabinets, catalog=self.catalog)
return dict(first, cabinets=cabinets, catalog=self.catalog, hub=self.hub)
def validate_assignment(self, item):
cabinet, game = item.get("cabinet"), item.get("game")
@@ -195,6 +276,39 @@ class Fleet:
raise ValueError("Unknown cabinet or game")
if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met")
if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime")
settings = item.get("game_config", {})
if not isinstance(settings, dict): raise ValueError("game_config must be an object")
limits = {"test_auto_win_seconds": (0.1, 300)}
limits.update({"galaga": {"score_to_win": (0, 1000000), "stages_to_win": (0, 100)},
"frogger": {"homes_to_win": (0, 5), "boards_to_win": (0, 99)},
"pacman": {"boards_to_win": (0, 100), "score_to_win": (0, 1000000)}}.get(game, {}))
credit_device = settings.get("rfid_credit_device")
if credit_device is not None:
if game != "pacman" or not isinstance(credit_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", credit_device):
raise ValueError("Invalid RFID credit device")
if not any(d.get("id") == credit_device and d.get("kind") == "rfid" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
raise ValueError("RFID credit device is missing from the cabinet PCB inventory")
if "rfid_credit_v1" not in self.rooms[cabinet].features:
raise ValueError("Update the game export: RFID credits require arcade-cabinet-2.3")
if self.rooms[cabinet].gpio != "real":
raise ValueError("RFID credits require real GPIO feedback")
if item.get("activate_after_win"):
raise ValueError("RFID credit scenarios require player Start, not automatic start")
if "rfid_allowed_uids" in settings:
uids = settings["rfid_allowed_uids"]
if credit_device is None or not isinstance(uids, list) or len(uids) > 100 or any(not isinstance(uid, str) or not re.fullmatch(r"[0-9A-F]{8}", uid) for uid in uids):
raise ValueError("RFID allowed UIDs must be a list of uppercase 8-digit hexadecimal IDs")
for key, value in settings.items():
if key in ("rfid_credit_device", "rfid_allowed_uids"): continue
if key not in limits or type(value) not in (int, float) or not math.isfinite(value):
raise ValueError("Unsupported game objective: " + str(key))
low, high = limits[key]
if not low <= value <= high or (key != "test_auto_win_seconds" and value != int(value)):
raise ValueError("Invalid game objective: " + key)
if settings and not self.rooms[cabinet].online():
raise ValueError(f"{cabinet} is offline; check its controller URL, cabinet ID and token before preparing objectives")
if settings and "assignment_config_v1" not in self.rooms[cabinet].features:
raise ValueError(f"Update the game export on {cabinet}: connected agent {self.rooms[cabinet].version} does not advertise assignment objectives (requires arcade-cabinet-2.1)")
allowed = set(self.catalog["cabinets"][cabinet]["actions"])
for event, actions in item.get("mappings", {}).items():
if event not in ("game_activated", "game_started", "game_won", "game_lost"):
@@ -210,7 +324,13 @@ class Fleet:
def operator(self, action, data=None):
data = data or {}
if action == "prepare_scenario":
if "expected_session" in data:
cabinet = data.get("cabinet")
if cabinet not in self.rooms or self.rooms[cabinet].state["session"] != data["expected_session"]:
raise ValueError("Cabinet session changed; review its current state before sending a command")
if action == "trigger_output":
self.trigger_output(data)
elif action == "prepare_scenario":
scenario_id = data.get("scenario")
scenario = self.catalog["scenarios"].get(scenario_id)
if not scenario: raise ValueError("Unknown scenario")
@@ -256,10 +376,47 @@ class Fleet:
self.advance()
return self.snapshot()
def trigger_output(self, data):
cabinet, action_id = data.get("cabinet"), data.get("action_id")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
room = self.rooms[cabinet]
request_id = data.get("request_id", "")
reason = data.get("reason", "")
if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,100}", request_id):
raise ValueError("Manual hardware command requires a unique request ID")
if not isinstance(reason, str) or not 3 <= len(reason.strip()) <= 200:
raise ValueError("Enter a reason (3–200 characters) for the hardware action")
if data.get("expected_session") != room.state["session"]:
raise ValueError("Cabinet session changed; review before triggering hardware")
if action_id not in self.catalog["cabinets"][cabinet]["actions"]:
raise ValueError("Hardware action is not configured for this cabinet")
receipts = room.state.setdefault("manual_requests", {})
if request_id in receipts:
if receipts[request_id] != [action_id, room.state["session"]]:
raise ValueError("Request ID was already used for another action/session")
return # A lost HTTP reply must not cause a second physical action.
if len(receipts) >= 10000:
raise ValueError("Manual action history limit reached; contact the administrator")
room.expire()
if not room.online(): raise ValueError("Cabinet is offline; no command sent")
if room.gpio != "real": raise ValueError("Cabinet must report real GPIO mode")
if not room.state["session"] or room.state["phase"] not in ("ready", "playing", "won", "lost"):
raise ValueError("Prepare the cabinet before triggering its hardware")
if any(c["status"] == "queued" for c in room.state["commands"]):
raise ValueError("Wait for the pending command or its timeout")
command = room.command("output")
command["action_id"] = action_id
command["source"] = "operator"
room.state["last_output"] = "requested"
receipts[request_id] = [action_id, room.state["session"]]
# Refuse new requests rather than evict deduplication receipts during a session.
room.log("Operator triggered " + str(action_id) + ": " + reason.strip())
room.save()
def prepare(self, item, scenario):
room = self.rooms[item["cabinet"]]
room.state.update(game=item["game"], mappings=item.get("mappings", {}),
variant=item.get("variant", "standard"), scenario=scenario)
variant=item.get("variant", "standard"), game_config=item.get("game_config", {}), scenario=scenario)
room.state.pop("gate", None)
room.state.pop("auto_start", None)
room.operator("reset" if item.get("activate_after_win") else "prepare")
@@ -363,6 +520,7 @@ def main():
parser.add_argument("--port", type=int, default=8766)
parser.add_argument("--database", default="arcade_room.sqlite3")
parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json")))
parser.add_argument("--hub-config", help="Optional operations hub names and service links")
args = parser.parse_args()
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
@@ -375,7 +533,11 @@ def main():
if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens):
parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets")
catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8"))
room = Fleet(Path(args.database), catalog)
try:
hub = hub_config(args.hub_config)
except (OSError, ValueError) as exc:
parser.error(str(exc))
room = Fleet(Path(args.database), catalog, hub=hub)
server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens))
print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True)
try: server.serve_forever()