Added setup folder with needed launch files - Update when new version is ready

This commit is contained in:
Jeff Luke committed 2026-10-07 06:06:03 -07:00
1 parent 91b7840535
commit 0dcada4db2
50 files changed
+3753 -11

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
launcher.ini
*.local.ini
logs/
state/
__pycache__/
+139
View File
@@ -0,0 +1,139 @@
# Start the Arcade stack
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).
| Mode | Starts |
| --- | --- |
| `controller` | Room controller; optionally opens the browser operator |
| `cabinet` | Managed game and, when enabled, its local Pi GPIO bridge |
| `all` | Controller plus cabinet; useful for Windows simulation or a Pi hosting everything |
For the usual installation, run **controller on the PC/Mac** and **cabinet on each Pi**. Keep the launcher terminal open. These scripts do not install boot services or software packages.
## Files to deploy
Keep the following directory structure, either by copying the repository or creating this deployment folder:
```text
ArcadeEscape/
Start-Arcade.ps1
start-arcade.sh
ArcadeEscape.exe # Windows, with any accompanying export files
ArcadeEscape.arm64 # Pi, with any accompanying export files
tools/
launch/ # launch.py, examples, your launcher.ini
room_controller/ # server.py, operator.html, catalog.json
pi_gpio/ # pi_gpio_bridge.py
```
Use the executable appropriate to that machine. You can instead point the INI at an executable elsewhere. **All relative INI paths resolve from the INI's folder**, regardless of where you launch the script. Review your existing export location: the project's presets may place builds one directory above the repository.
## Windows controller
Install Python 3 first. From the project/deployment root:
```powershell
Copy-Item tools/launch/controller.example.ini tools/launch/launcher.ini
notepad tools/launch/launcher.ini
```
Fill in `operator_token` and `cabinet_tokens` with the same credentials already used by your cabinets. INI values here are **not surrounded by quotes**, except the JSON strings inside `cabinet_tokens`:
```ini
operator_token = YOUR_EXISTING_OPERATOR_TOKEN
cabinet_tokens = {"cabinet_01":"YOUR_EXISTING_CABINET_TOKEN"}
```
Each token must be distinct and at least 16 characters. Existing `ARCADE_OPERATOR_TOKEN`, `ARCADE_CABINET_TOKENS`, and legacy `ARCADE_CABINET_TOKEN` environment variables take precedence. Reuse credentials; the launcher does not generate or rotate them.
The launcher prints the configuration path and token source without printing the tokens. It warns when an environment operator/cabinet token overrides a different INI value, and rejects surrounding token quotes.
### Browser says Unauthorized
The controller was reached, but its operator token does not match what was entered. Use the **operator** token, not a cabinet token, and open the exact operator URL printed by the launcher. “Waiting for the room operator” in the game is normal until a scenario is prepared.
If you configured `operator_token` in `launcher.ini` but the launcher reports an environment override, stop your running stack with Ctrl+C and clear the old variable **in the same PowerShell terminal you use to launch**:
```powershell
Remove-Item Env:ARCADE_OPERATOR_TOKEN -ErrorAction SilentlyContinue
.\Start-Arcade.ps1 -Mode all -Browser
```
Use `-Mode controller` instead if this machine only hosts the controller. Enter the INI's operator token in the browser, without quotes. Clearing the variable affects only that PowerShell session; it does not delete an INI value or change a stored Windows environment variable. The controller must be restarted to pick up credential changes. Never paste real tokens into troubleshooting messages.
**Set `database` to your existing controller database** when upgrading. The example defaults to a new `tools/launch/state/arcade_room.sqlite3`; that would start a fresh controller history instead of using a database at the project root. To use a root-level database from the default INI location, set `database = ../../arcade_room.sqlite3`.
Then validate and start:
```powershell
.\Start-Arcade.ps1 -Mode controller -Check
.\Start-Arcade.ps1 -Mode controller -Browser
```
If PowerShell blocks script execution, the same launcher can be run directly without changing execution policy:
```powershell
py -3 tools/launch/launch.py controller --check
py -3 tools/launch/launch.py controller --browser
```
## Pi cabinet
Install the GPIO dependencies once, following the [GPIO guide](../pi_gpio/README.md). Configure the game's physical controls using the normal launcher before starting managed mode.
```sh
sudo apt update
sudo apt install python3-gpiozero python3-lgpio
cp tools/launch/pi.example.ini tools/launch/launcher.ini
nano tools/launch/launcher.ini
chmod +x ArcadeEscape.arm64
```
Set the game executable path, cabinet ID, bridge controller ID, and BCM pin allowlist. GPIO is enabled in this Pi example. Leave `url` and `token` blank to reuse the game's existing `user://arcade_network.cfg`, or supply them here:
```ini
[cabinet]
executable = ../../ArcadeEscape.arm64
id = cabinet_01
url = http://CONTROLLER_IP:8766
token = YOUR_EXISTING_CABINET_TOKEN
gpio = true
```
Do not copy operator credentials to a cabinet-only Pi. URL/token environment variables still override these INI values. Unlike the launcher INI, Godot's own CFG file requires double quotes around string values.
```sh
bash start-arcade.sh cabinet --check
bash start-arcade.sh cabinet
```
This starts the GPIO bridge first, waits for its local heartbeat endpoint, then launches the managed game with real GPIO enabled. The bridge uses **8765 locally**; the room URL uses **8766 on the controller**. `--check` validates files, settings, dependencies, and service port availability; it does not drive GPIO or prove wiring/permissions/network credentials work.
For Pi gameplay/network testing without physical outputs, set `gpio = false`. For a Pi that also hosts the controller, add the `[controller]` settings from the controller example and use `all` mode; blank cabinet URL/token then use that local controller's values.
## Windows all-in-one test
Copy `windows-all.example.ini` to `launcher.ini`, fill in the controller tokens, and set the Windows executable path. Keep `gpio = false`:
```powershell
.\Start-Arcade.ps1 -Mode all -Check
.\Start-Arcade.ps1 -Mode all -Browser
```
The game connects to the local controller using its cabinet ID's token. GPIO runs in simulation; Windows does not start the physical Pi bridge. To connect a Windows game to a remote controller instead, configure the cabinet section and use `-Mode cabinet`.
## Stopping, logs, and configuration
- **Ctrl+C** stops the stack started by this invocation. On the Pi, the bridge receives SIGTERM so its normal output cleanup runs.
- **Escape/game exit** also stops services started by that same invocation. A controller launched separately on another machine stays running.
- An unexpected child exit stops the other owned processes and returns an error; the launcher does not automatically restart a game or repeat hardware actions.
- An occupied service port causes startup to stop. Existing processes are never killed or silently adopted.
- Logs append to `tools/launch/logs/controller.log`, `gpio.log`, and `cabinet.log` by default. Check them for application errors after startup.
- Keep the database and GPIO request ledger across restarts. Do not delete them to replay a reward.
- Use `-Config C:\path\launcher.ini` on Windows or `--config /path/launcher.ini` on the Pi for another configuration file.
- `launcher.ini`, `*.local.ini`, the default logs, and the default state folder are Git-ignored within `tools/launch/`. Keep other private configuration paths out of Git yourself.
- Controller firewall rules, Godot exports, catalog generation, and GPIO wiring are still one-time deployment tasks; see the [room controller guide](../room_controller/README.md).
For macOS controller use: `bash start-arcade.sh controller --browser`, with Python 3 and a configured controller INI.
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Run the Arcade controller and/or cabinet with supervised child processes."""
import argparse
import configparser
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
import urllib.request
import webbrowser
ROOT = Path(__file__).resolve().parents[2]
def credential(env, name, configured, label):
inherited = env.get(name, '')
selected = inherited or configured
source = 'environment variable '+name if inherited else 'launcher INI'
if selected:
print(f'{label} source: {source}', flush=True)
if inherited and configured and inherited != configured:
print(f'WARNING: {name} overrides a DIFFERENT value in the launcher INI. Clear that variable in the launching terminal to use the INI.', flush=True)
if selected and (selected[0] in "\"'" or selected[-1] in "\"'"):
raise ValueError(f'{label} has surrounding quote characters. In launcher.ini use the token without quotes; enter the same unquoted token in the browser.')
return selected
def plan(config_path, mode):
config_path = Path(config_path).resolve()
config = configparser.ConfigParser(interpolation=None)
if not config.read(config_path, encoding='utf-8-sig'):
raise ValueError(f'Configuration not found: {config_path}. Copy an example INI first.')
base = config_path.parent
def value(section, key, default=''):
return config.get(section, key, fallback=default).strip()
def path(section, key, default):
return (base / Path(os.path.expanduser(value(section, key, default)))).resolve()
def require_file(filename):
if not filename.is_file(): raise ValueError(f'File not found: {filename}')
env = dict(os.environ)
jobs = []
if mode in ('controller', 'all'):
script = ROOT / 'tools/room_controller/server.py'
catalog = path('controller', 'catalog', '../room_controller/catalog.json')
require_file(script)
require_file(script.with_name('operator.html'))
require_file(catalog)
operator = credential(env, 'ARCADE_OPERATOR_TOKEN', value('controller', 'operator_token'), 'Operator token')
tokens = json.loads(env.get('ARCADE_CABINET_TOKENS') or value('controller', 'cabinet_tokens', '{}'))
if not isinstance(tokens, dict): raise ValueError('cabinet_tokens must be a JSON object')
legacy = env.get('ARCADE_CABINET_TOKEN', '')
if legacy: tokens.setdefault('cabinet_01', legacy)
if len(operator) < 16 or not tokens 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):
raise ValueError('Configure distinct operator and cabinet tokens of at least 16 characters')
server_env = dict(env, ARCADE_OPERATOR_TOKEN=operator, ARCADE_CABINET_TOKENS=json.dumps(tokens))
server_env.pop('ARCADE_CABINET_TOKEN', None)
bind = value('controller', 'bind', '127.0.0.1')
port = config.getint('controller', 'port', fallback=8766)
if not 1 <= port <= 65535: raise ValueError('Controller port must be between 1 and 65535')
database = path('controller', 'database', 'state/arcade_room.sqlite3')
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 mode in ('cabinet', 'all'):
executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64')
require_file(executable)
if os.name != 'nt' and not os.access(executable, os.X_OK):
raise ValueError(f'Game is not executable; run chmod +x "{executable}"')
cabinet_id = value('cabinet', 'id', 'cabinet_01')
if not cabinet_id or any(c.isspace() for c in cabinet_id): raise ValueError('Invalid cabinet ID')
cabinet_env = dict(env)
room_url = env.get('ARCADE_ROOM_URL') or value('cabinet', 'url')
token = credential(env, 'ARCADE_CABINET_TOKEN', value('cabinet', 'token'), 'Cabinet token')
if mode == 'all':
room_url = room_url or url
token = token or tokens.get(cabinet_id, '')
if token != tokens.get(cabinet_id): raise ValueError('Cabinet token does not match this controller\'s token for its ID')
# Omitted values preserve the cabinet's existing user://arcade_network.cfg.
if room_url: cabinet_env['ARCADE_ROOM_URL'] = room_url
if token: cabinet_env['ARCADE_CABINET_TOKEN'] = token
real_gpio = config.getboolean('cabinet', 'gpio', fallback=False)
if real_gpio:
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')
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))
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))
return jobs, path('launcher', 'logs', 'logs')
def check_ports(jobs):
for job in jobs:
if not job['port']: continue
with socket.socket() as probe:
try: probe.bind(job['port'])
except OSError as exc:
raise ValueError(f"{job['name']} port {job['port'][1]} is unavailable. Stop the existing service or use the appropriate launcher mode.") from exc
def wait_ready(job, process):
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
if process.poll() is not None: raise RuntimeError(f"{job['name']} exited during startup; check its log")
try:
data = b'{}' if job['name'] == 'gpio' else None
with urllib.request.urlopen(urllib.request.Request(job['ready'], data=data), timeout=.5) as response:
if response.status == 200: return
except (OSError, ValueError): pass
time.sleep(.1)
raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False):
check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True)
children, files = [], []
try:
for job in jobs:
job['directory'].mkdir(parents=True, exist_ok=True)
output = open(logs / (job['name']+'.log'), 'ab', buffering=0)
files.append(output)
flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' and job['name'] != 'cabinet' else 0
process = subprocess.Popen(job['command'], env=job['env'], cwd=job['directory'], stdout=output, stderr=subprocess.STDOUT, creationflags=flags)
children.append((job, process))
if job['ready']: wait_ready(job, process)
print(f"Started {job['name']} (PID {process.pid}); log: {logs / (job['name']+'.log')}", flush=True)
if job['name'] == 'controller':
print('Operator: '+job['ready'], flush=True)
if open_browser: webbrowser.open(job['ready'])
print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True)
while True:
for job, process in children:
code = process.poll()
if code is not None:
print(f"{job['name']} exited ({code}); stopping this stack.", flush=True)
return code if code else (0 if job['name'] == 'cabinet' else 1)
time.sleep(.25)
except KeyboardInterrupt:
return 0
finally:
# Only owned children; never terminate an unrelated process on a service port.
for job, process in reversed(children):
if process.poll() is None:
process.terminate()
try: process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
for output in files: output.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['controller', 'cabinet', 'all'])
parser.add_argument('--config', default=str(Path(__file__).with_name('launcher.ini')))
parser.add_argument('--check', action='store_true', help='Validate settings and ports without starting anything')
parser.add_argument('--browser', action='store_true', help='Open the local operator browser after controller startup')
args = parser.parse_args()
def interrupted(*_): raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
try:
print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True)
jobs, logs = plan(args.config, args.mode)
if args.check:
check_ports(jobs)
print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs))
print('Logs: '+str(logs))
return 0
return run(jobs, logs, args.browser)
except (ValueError, OSError, RuntimeError, configparser.Error) as exc:
print('Startup failed: '+str(exc), file=sys.stderr)
return 1
except KeyboardInterrupt:
return 0
if __name__ == '__main__': sys.exit(main())