Added setup guide and launch tools

This commit is contained in:
Jeff Luke committed 2026-10-04 12:10:27 -07:00
1 parent 5c1739c6bf
commit 27b12beff8
15 files changed
+612 -5

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ The games include attract/title screens, sound, and game-specific visual treatme
## Start here ## Start here
- **Start the services with one command:** [Windows and Pi launch scripts](tools/launch/README.md)
- **Play or configure controls:** [Launcher and cabinet controls](games/launcher/README.md) - **Play or configure controls:** [Launcher and cabinet controls](games/launcher/README.md)
- **Deploy the room and operator:** [Controller deployment guide](tools/room_controller/README.md) - **Deploy the room and operator:** [Controller deployment guide](tools/room_controller/README.md)
- **Connect real Pi outputs:** [GPIO setup and LED test](tools/pi_gpio/README.md) - **Connect real Pi outputs:** [GPIO setup and LED test](tools/pi_gpio/README.md)
+18
View File
@@ -0,0 +1,18 @@
param(
[ValidateSet('controller', 'cabinet', 'all')][string]$Mode = 'controller',
[string]$Config = "$PSScriptRoot/tools/launch/launcher.ini",
[switch]$Check,
[switch]$Browser
)
$ErrorActionPreference = 'Stop'
$launchArguments = @("$PSScriptRoot/tools/launch/launch.py", $Mode, '--config', $Config)
if ($Check) { $launchArguments += '--check' }
if ($Browser) { $launchArguments += '--browser' }
if (Get-Command py -ErrorAction SilentlyContinue) {
& py -3 @launchArguments
} elseif (Get-Command python -ErrorAction SilentlyContinue) {
& python @launchArguments
} else {
throw 'Python 3 is required. Install it, then reopen PowerShell.'
}
exit $LASTEXITCODE
+2 -2
View File
@@ -7,8 +7,8 @@
[resource] [resource]
script = ExtResource("1_v1x8f") script = ExtResource("1_v1x8f")
cabinet_id = &"cabinet_01" cabinet_id = &"cabinet_01"
display_name = "Cabinet 01" display_name = "Master Cabinet"
capabilities = Array[StringName]([&"joystick_4way", &"button_1", &"start_button", &"joystick_horizontal"]) capabilities = Array[StringName]([&"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"])
hardware_config = ExtResource("1_b4cnm") hardware_config = ExtResource("1_b4cnm")
footprint_width_m = 0.9144000000000001 footprint_width_m = 0.9144000000000001
footprint_depth_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001
+122
View File
@@ -0,0 +1,122 @@
# Start the Arcade stack
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.
**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.
+5 -3
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3] [gd_scene format=3 uid="uid://bnpmx73lmsumv"]
[ext_resource type="Script" path="res://operator/arcade_room_operator.gd" id="1"]
[node name="ArcadeRoomOperator" type="Control"] [ext_resource type="Script" uid="uid://dxiyqt7t5kys3" path="res://operator/arcade_room_operator.gd" id="1"]
[node name="ArcadeRoomOperator" type="Control" unique_id=1735851866]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
arcade_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v python3 >/dev/null 2>&1; then
echo 'Python 3 is required. Install it before starting Arcade Escape.' >&2
exit 1
fi
exec python3 "$arcade_root/tools/launch/launch.py" "$@"
+98
View File
@@ -0,0 +1,98 @@
import importlib.util
import json
import os
from pathlib import Path
import socket
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('arcade_launch', ROOT / 'tools/launch/launch.py')
launch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(launch)
class LaunchTests(unittest.TestCase):
def config(self, folder, gpio='false'):
filename = Path(folder) / 'launcher.ini'
filename.write_text(f'''[controller]
operator_token = operator-test-token-1234
cabinet_tokens = {json.dumps({'cabinet_01':'cabinet-test-token-5678'})}
catalog = {ROOT / 'tools/room_controller/catalog.json'}
database = state/test.sqlite3
[cabinet]
executable = {sys.executable}
id = cabinet_01
gpio = {gpio}
''')
return filename
def test_combined_plan_and_relative_paths(self):
with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {}, clear=True):
jobs, logs = launch.plan(self.config(folder), 'all')
self.assertEqual([j['name'] for j in jobs], ['controller', 'cabinet'])
self.assertEqual(jobs[1]['env']['ARCADE_CABINET_TOKEN'], 'cabinet-test-token-5678')
self.assertEqual(jobs[1]['env']['ARCADE_ROOM_URL'], 'http://127.0.0.1:8766')
self.assertEqual(logs, Path(folder) / 'logs')
self.assertNotIn('cabinet-test-token-5678', ' '.join(jobs[1]['command']))
self.assertNotIn('--gpio=real', jobs[1]['command'])
def test_cabinet_preserves_godot_config_fallback(self):
with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {}, clear=True):
jobs, _ = launch.plan(self.config(folder), 'cabinet')
self.assertNotIn('ARCADE_CABINET_TOKEN', jobs[0]['env'])
self.assertNotIn('ARCADE_ROOM_URL', jobs[0]['env'])
def test_mismatched_combined_credentials_rejected(self):
with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {'ARCADE_CABINET_TOKEN':'different-token-1234'}, clear=True):
with self.assertRaisesRegex(ValueError, 'does not match'):
launch.plan(self.config(folder), 'all')
def test_real_gpio_rejected_on_windows(self):
with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {}, clear=True), patch.object(sys, 'platform', 'win32'):
with self.assertRaisesRegex(ValueError, 'Linux Pi'):
launch.plan(self.config(folder, 'true'), 'cabinet')
def test_pi_plan_starts_bridge_before_real_game(self):
with tempfile.TemporaryDirectory() as folder, patch.dict(os.environ, {}, clear=True), patch.object(sys, 'platform', 'linux'), patch.object(launch.importlib.util, 'find_spec', return_value=object()):
jobs, _ = launch.plan(self.config(folder, 'true'), 'cabinet')
self.assertEqual([j['name'] for j in jobs], ['gpio', 'cabinet'])
self.assertEqual(jobs[0]['env']['GPIOZERO_PIN_FACTORY'], 'lgpio')
self.assertIn('cabinet_01_gpio', jobs[0]['command'])
self.assertIn('--gpio=real', jobs[1]['command'])
self.assertEqual(jobs[0]['port'], ('127.0.0.1', 8765))
def test_occupied_port_does_not_get_reused_or_killed(self):
with socket.socket() as existing:
existing.bind(('127.0.0.1', 0))
existing.listen()
with self.assertRaisesRegex(ValueError, 'unavailable'):
launch.check_ports([dict(name='test', port=existing.getsockname())])
self.assertGreater(existing.fileno(), -1)
def test_game_exit_stops_owned_server(self):
with tempfile.TemporaryDirectory() as folder:
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
service = dict(name='controller', command=[sys.executable, '-u', '-m', 'http.server', str(port), '--bind', '127.0.0.1'], env=dict(os.environ), directory=Path(folder), port=('127.0.0.1',port), ready=f'http://127.0.0.1:{port}/')
game = dict(name='cabinet', command=[sys.executable,'-c','import time; time.sleep(.3)'], env=dict(os.environ), directory=Path(folder), port=None, ready=None)
self.assertEqual(launch.run([service,game], Path(folder)/'logs'), 0)
with socket.socket() as probe:
self.assertNotEqual(probe.connect_ex(('127.0.0.1',port)), 0)
def test_startup_failure_cleans_up_previous_child(self):
with tempfile.TemporaryDirectory() as folder:
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
service = dict(name='controller', command=[sys.executable, '-u', '-m', 'http.server', str(port), '--bind', '127.0.0.1'], env=dict(os.environ), directory=Path(folder), port=('127.0.0.1',port), ready=f'http://127.0.0.1:{port}/')
broken = dict(name='cabinet', command=[str(Path(folder)/'missing-executable')], env=dict(os.environ), directory=Path(folder), port=None, ready=None)
with self.assertRaises(OSError): launch.run([service,broken], Path(folder)/'logs')
with socket.socket() as probe:
self.assertNotEqual(probe.connect_ex(('127.0.0.1',port)), 0)
if __name__ == '__main__': unittest.main()
+5
View File
@@ -0,0 +1,5 @@
launcher.ini
*.local.ini
logs/
state/
__pycache__/
+122
View File
@@ -0,0 +1,122 @@
# Start the Arcade stack
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.
**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.
+12
View File
@@ -0,0 +1,12 @@
[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 = {}
+180
View File
@@ -0,0 +1,180 @@
#!/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 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 = env.get('ARCADE_OPERATOR_TOKEN') or value('controller', '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 = env.get('ARCADE_CABINET_TOKEN') or value('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:
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())
+17
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
[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
+2
View File
@@ -1,5 +1,7 @@
# Real GPIO on the Raspberry Pi # Real GPIO on the Raspberry Pi
After completing the hardware setup below, the [stack launch scripts](../launch/README.md) can start the bridge and managed cabinet together.
The former HardwareManager only logged `[MOCK GPIO]`; it could not light an LED. The standalone launcher also did not forward game events to hardware. Both paths are now connected. GPIO numbers in HardwareAction resources are **BCM numbers**, not physical header positions. The former HardwareManager only logged `[MOCK GPIO]`; it could not light an LED. The standalone launcher also did not forward game events to hardware. Both paths are now connected. GPIO numbers in HardwareAction resources are **BCM numbers**, not physical header positions.
## 1. Wire the LED (Pi powered off) ## 1. Wire the LED (Pi powered off)
+2
View File
@@ -2,6 +2,8 @@
This controller is independent of Odin. It uses Python's standard library, authenticated HTTP, and SQLite; no Odin services or dependencies are needed. This controller is independent of Odin. It uses Python's standard library, authenticated HTTP, and SQLite; no Odin services or dependencies are needed.
For supervised startup on Windows or the Pi, use the [stack launch scripts](../launch/README.md). The manual commands below remain available for troubleshooting.
## Available now ## Available now
- A touch-friendly browser operator for the Mac mini and iPads, served by the controller itself. - A touch-friendly browser operator for the Mac mini and iPads, served by the controller itself.