Added setup folder with needed launch files - Update when new version is ready
This commit is contained in:
1 parent
91b7840535
commit
0dcada4db2
50 files changed
+3753
-11
No files matched your search
@@ -0,0 +1,5 @@
|
||||
launcher.ini
|
||||
*.local.ini
|
||||
logs/
|
||||
state/
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -0,0 +1,17 @@
|
||||
[launcher]
|
||||
logs = logs
|
||||
|
||||
[cabinet]
|
||||
; Relative to this INI, or use an absolute executable path.
|
||||
executable = ../../ArcadeEscape.arm64
|
||||
id = cabinet_01
|
||||
; Leave these blank to use the game's existing user://arcade_network.cfg.
|
||||
url =
|
||||
token =
|
||||
gpio = true
|
||||
|
||||
[gpio]
|
||||
controller = cabinet_01_gpio
|
||||
pins = 17
|
||||
; Preserve this ledger across launches to prevent repeated rewards.
|
||||
ledger = ~/.local/state/arcade_gpio/requests.sqlite3
|
||||
@@ -0,0 +1,17 @@
|
||||
[launcher]
|
||||
logs = logs
|
||||
|
||||
[cabinet]
|
||||
; Relative to this INI, or use an absolute executable path.
|
||||
executable = ../../ArcadeEscape.arm64
|
||||
id = cabinet_02
|
||||
; Leave these blank to use the game's existing user://arcade_network.cfg.
|
||||
url =
|
||||
token =
|
||||
gpio = true
|
||||
|
||||
[gpio]
|
||||
controller = cabinet_02_gpio
|
||||
pins = 17
|
||||
; Preserve this ledger across launches to prevent repeated rewards.
|
||||
ledger = ~/.local/state/arcade_gpio/requests.sqlite3
|
||||
Reference in new issue
Block a user