diff --git a/README.md b/README.md index 50a4c32..8929d01 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The games include attract/title screens, sound, and game-specific visual treatme ## 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) - **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) diff --git a/Start-Arcade.ps1 b/Start-Arcade.ps1 new file mode 100644 index 0000000..a9b5d2f --- /dev/null +++ b/Start-Arcade.ps1 @@ -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 diff --git a/data/cabinets/cabinet_01.tres b/data/cabinets/cabinet_01.tres index af3c88c..9b0730a 100644 --- a/data/cabinets/cabinet_01.tres +++ b/data/cabinets/cabinet_01.tres @@ -7,8 +7,8 @@ [resource] script = ExtResource("1_v1x8f") cabinet_id = &"cabinet_01" -display_name = "Cabinet 01" -capabilities = Array[StringName]([&"joystick_4way", &"button_1", &"start_button", &"joystick_horizontal"]) +display_name = "Master Cabinet" +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") footprint_width_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001 diff --git a/docs/SETUP-GUIDE.md b/docs/SETUP-GUIDE.md new file mode 100644 index 0000000..ff5cd3f --- /dev/null +++ b/docs/SETUP-GUIDE.md @@ -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. diff --git a/operator/ArcadeRoomOperator.tscn b/operator/ArcadeRoomOperator.tscn index 5c6dd9b..7e80f12 100644 --- a/operator/ArcadeRoomOperator.tscn +++ b/operator/ArcadeRoomOperator.tscn @@ -1,6 +1,8 @@ -[gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://operator/arcade_room_operator.gd" id="1"] -[node name="ArcadeRoomOperator" type="Control"] +[gd_scene format=3 uid="uid://bnpmx73lmsumv"] + +[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 anchors_preset = 15 anchor_right = 1.0 diff --git a/start-arcade.sh b/start-arcade.sh new file mode 100644 index 0000000..4040fb7 --- /dev/null +++ b/start-arcade.sh @@ -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" "$@" diff --git a/tests/room_network/test_launch.py b/tests/room_network/test_launch.py new file mode 100644 index 0000000..d961e61 --- /dev/null +++ b/tests/room_network/test_launch.py @@ -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() diff --git a/tools/launch/.gitignore b/tools/launch/.gitignore new file mode 100644 index 0000000..8abae6e --- /dev/null +++ b/tools/launch/.gitignore @@ -0,0 +1,5 @@ +launcher.ini +*.local.ini +logs/ +state/ +__pycache__/ diff --git a/tools/launch/README.md b/tools/launch/README.md new file mode 100644 index 0000000..ff5cd3f --- /dev/null +++ b/tools/launch/README.md @@ -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. diff --git a/tools/launch/controller.example.ini b/tools/launch/controller.example.ini new file mode 100644 index 0000000..561246d --- /dev/null +++ b/tools/launch/controller.example.ini @@ -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 = {} diff --git a/tools/launch/launch.py b/tools/launch/launch.py new file mode 100644 index 0000000..2a8b562 --- /dev/null +++ b/tools/launch/launch.py @@ -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()) diff --git a/tools/launch/pi.example.ini b/tools/launch/pi.example.ini new file mode 100644 index 0000000..40da5a8 --- /dev/null +++ b/tools/launch/pi.example.ini @@ -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 diff --git a/tools/launch/windows-all.example.ini b/tools/launch/windows-all.example.ini new file mode 100644 index 0000000..f007a55 --- /dev/null +++ b/tools/launch/windows-all.example.ini @@ -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 diff --git a/tools/pi_gpio/README.md b/tools/pi_gpio/README.md index e87b025..4d8bb13 100644 --- a/tools/pi_gpio/README.md +++ b/tools/pi_gpio/README.md @@ -1,5 +1,7 @@ # 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. ## 1. Wire the LED (Pi powered off) diff --git a/tools/room_controller/README.md b/tools/room_controller/README.md index 9014b3b..ca6d813 100644 --- a/tools/room_controller/README.md +++ b/tools/room_controller/README.md @@ -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. +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 - A touch-friendly browser operator for the Mac mini and iPads, served by the controller itself.