Compare commits

..
2 Commits
51 changed files with 3755 additions and 11 deletions

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
/android/ /android/
Godot_v4.7.2-stable_win64_console.exe
Godot_v4.7.2-stable_win64.exe
+57 -1
View File
@@ -155,12 +155,66 @@ $env:ARCADE_CABINET_TOKENS = '{"cabinet_01":"your-generated-cabinet-one-token","
py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3 py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3
``` ```
Keep the database at a stable location and back it up. Allow TCP **8766** through the controller machine's firewall for the room LAN. Keep real tokens and deployment databases out of source control. Keep the database at a stable location and back it up. Allow TCP **8766** through the controller machine's firewall for the room LAN. The assigned internal tokens are documented in the [cabinet setup guide](docs/cabinet_setup.md), as requested for this installation. Keep deployment databases out of source control.
Open `http://CONTROLLER_IP:8766/` on the staff computer or iPad and enter the operator token. The iPad needs only a browser. Use a trusted isolated LAN; the default HTTP connection is not encrypted. See the [deployment guide](tools/room_controller/README.md) for network and credential details. Open `http://CONTROLLER_IP:8766/` on the staff computer or iPad and enter the operator token. The iPad needs only a browser. Use a trusted isolated LAN; the default HTTP connection is not encrypted. See the [deployment guide](tools/room_controller/README.md) for network and credential details.
## 5. Connect cabinets to the controller ## 5. Connect cabinets to the controller
### PC versus Pi: which configuration file?
`arcade_network.cfg` configures a **Godot client**, not the Python controller. Its contents depend on the role that Godot runs in, not just the operating system.
| Machine / role | Where settings belong | Credentials |
| --- | --- | --- |
| PC running the Python controller + browser | `tools/launch/launcher.ini`, `[controller]` | `operator_token` and JSON `cabinet_tokens` for all cabinets |
| PC running the optional Godot `--operator` app | `user://arcade_network.cfg`, `[network]` | `url` and `operator_token` |
| Pi running a managed cabinet | `user://arcade_network.cfg`, `[network]` | `url`, its own `cabinet_id`, and its own `cabinet_token` |
| PC running a game as a test cabinet | Same cabinet fields as the Pi | That test cabinet's ID/token; do not duplicate an active Pi's ID |
**For the normal browser-operator setup, the PC does not need an `arcade_network.cfg`.** Enter the operator token in the browser; the server takes its credentials from the launcher/environment.
Example Pi Godot configuration:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="CABINET_01_TOKEN"
```
Optional PC Godot operator configuration (only for `--operator`, with the controller on that same PC):
```ini
[network]
url="http://127.0.0.1:8766"
operator_token="OPERATOR_TOKEN"
```
The Pi's URL must use the **PC's LAN IP**, never `127.0.0.1` unless the controller actually runs on that Pi. Both client roles use controller port **8766**. GPIO port **8765** is separate and stays local to the Pi.
Godot CFG strings require double quotes. Launcher INI scalar values are unquoted; the `cabinet_tokens` JSON object still uses JSON quotes.
When using the scripts, nonempty `[cabinet] url` and `token` values in `launcher.ini` override Godot's CFG. Existing `ARCADE_ROOM_URL` / `ARCADE_CABINET_TOKEN` environment values override those launcher values. Leave the INI URL/token blank to fall back to the CFG, and clear stale environment variables if needed. The scripts always pass their configured `[cabinet] id` (default `cabinet_01`) as `--cabinet`, overriding the CFG's ID: set the correct ID on every Pi even when using the CFG for its token.
### Generate a 16-character token
These are application authentication tokens, not SSH keys. Generate a separate token for each cabinet and for the operator:
```powershell
# Windows
py -3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
```sh
# Pi / macOS / Linux
python3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
Each command produces exactly 16 URL-safe characters. Preserve capitalization and any `-` or `_`, including a trailing hyphen. Restart the affected controller/client after changing credentials.
The supplied internal token assignments, complete controller configuration, and individual Cabinet 1–7 examples are in the [cabinet setup guide](docs/cabinet_setup.md). Use those assigned tokens for the current installation; generate new ones when provisioning additional identities or deliberately replacing a token.
On each cabinet, create **arcade_network.cfg** in the application's Godot user-data directory: On each cabinet, create **arcade_network.cfg** in the application's Godot user-data directory:
```ini ```ini
@@ -226,6 +280,8 @@ The sample [proof_of_concept](data/scenarios/proof_of_concept.tres) connects a P
The older `-- --operator` Godot mode remains available for Cabinet 01 quick controls. Use the browser interface for multiple cabinets and the room map. The older `-- --operator` Godot mode remains available for Cabinet 01 quick controls. Use the browser interface for multiple cabinets and the room map.
For a sequential two-Pi test, choose **Cabinet 1 -> Cabinet 2**: winning Pac-Man on the first cabinet loads and automatically starts Galaga on the second. See the [chain-test walkthrough](tools/room_controller/README.md#two-pi-chain-test-cabinet-1-wins---cabinet-2-starts).
## Troubleshooting ## Troubleshooting
| Symptom | Check | | Symptom | Check |
+2
View File
@@ -7,6 +7,8 @@ extends Resource
@export var game: GameDefinition @export var game: GameDefinition
@export var variant: StringName = &"standard" @export var variant: StringName = &"standard"
## Managed controller: load and auto-start only after this cabinet wins.
@export var activate_after_win: StringName = &""
@export var event_mappings: Array[ScenarioEventMapping] = [] @export var event_mappings: Array[ScenarioEventMapping] = []
+3
View File
@@ -153,6 +153,8 @@ func _create_working_copy() -> void:
source_assignment.variant source_assignment.variant
) )
assignment.activate_after_win = source_assignment.activate_after_win
assignment.event_mappings = ( assignment.event_mappings = (
_duplicate_mappings( _duplicate_mappings(
source_assignment.event_mappings source_assignment.event_mappings
@@ -257,6 +259,7 @@ func _capture_scenario_state(
"cabinet_id": StringName(), "cabinet_id": StringName(),
"game_id": StringName(), "game_id": StringName(),
"variant": assignment.variant, "variant": assignment.variant,
"activate_after_win": assignment.activate_after_win,
"event_mappings": [], "event_mappings": [],
} }
+3 -1
View File
@@ -18,9 +18,11 @@
[ext_resource type="Resource" uid="uid://yiejlr5h143m" path="res://data/games/space_invaders.tres" id="11_space_invaders"] [ext_resource type="Resource" uid="uid://yiejlr5h143m" path="res://data/games/space_invaders.tres" id="11_space_invaders"]
[ext_resource type="Resource" path="res://data/games/robotron.tres" id="12_robotron"] [ext_resource type="Resource" path="res://data/games/robotron.tres" id="12_robotron"]
[ext_resource type="Resource" path="res://data/scenarios/cabinet_chain_test.tres" id="chain_test"]
[resource] [resource]
script = ExtResource("4_algy0") script = ExtResource("4_algy0")
scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("7_gbm0a"), ExtResource("8_ib4xl"), ExtResource("10_b427t")]) scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("7_gbm0a"), ExtResource("8_ib4xl"), ExtResource("10_b427t"), ExtResource("chain_test")])
cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a")]) cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a")])
games = Array[ExtResource("2_an0il")]([ExtResource("4_bfrqh"), ExtResource("5_b427t"), ExtResource("8_0ke0w"), ExtResource("9_0ke0w"), ExtResource("10_8pm0t"), ExtResource("11_space_invaders"), ExtResource("12_robotron")]) games = Array[ExtResource("2_an0il")]([ExtResource("4_bfrqh"), ExtResource("5_b427t"), ExtResource("8_0ke0w"), ExtResource("9_0ke0w"), ExtResource("10_8pm0t"), ExtResource("11_space_invaders"), ExtResource("12_robotron")])
metadata/_custom_type_script = "uid://diqnrxrhwx2ug" metadata/_custom_type_script = "uid://diqnrxrhwx2ug"
+25
View File
@@ -0,0 +1,25 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3]
[ext_resource type="Script" path="res://core/scenarios/scenario_definition.gd" id="1"]
[ext_resource type="Script" path="res://core/scenarios/scenario_assignment.gd" id="2"]
[ext_resource type="Resource" path="res://data/cabinets/cabinet_01.tres" id="3"]
[ext_resource type="Resource" path="res://data/cabinets/cabinet_02.tres" id="4"]
[ext_resource type="Resource" path="res://data/games/pacman.tres" id="5"]
[ext_resource type="Resource" path="res://data/games/galaga.tres" id="6"]
[sub_resource type="Resource" id="First"]
script = ExtResource("2")
cabinet = ExtResource("3")
game = ExtResource("5")
[sub_resource type="Resource" id="Second"]
script = ExtResource("2")
cabinet = ExtResource("4")
game = ExtResource("6")
activate_after_win = &"cabinet_01"
[resource]
script = ExtResource("1")
scenario_id = &"cabinet_chain_test"
display_name = "Cabinet 1 -> Cabinet 2"
assignments = Array[ExtResource("2")]([SubResource("First"), SubResource("Second")])
+243
View File
@@ -0,0 +1,243 @@
# Internal cabinet setup and assigned tokens
This guide records the supplied credentials for the internal Arcade Escape installation. It documents configuration only: adding a row here does not update a running controller, create cabinet resources, or deploy a Pi.
## Assigned tokens
| Role | Cabinet ID | Token |
| --- | --- | --- |
| Operator | — | `Yu1qH7nz_aMUTlkk` |
| Cabinet 1 | `cabinet_01` | `K32qg0lYioXmzWAK` |
| Cabinet 2 | `cabinet_02` | `LY9ATvM3wkTxJg0x` |
| Cabinet 3 | `cabinet_03` | `qZSD9eM2dmeRtmVA` |
| Cabinet 4 | `cabinet_04` | `2xXcj0sUbOzQCn2j` |
| Cabinet 5 | `cabinet_05` | `weISFvFBOt6pxfk-` |
| Cabinet 6 | `cabinet_06` | `h-lP-SfQMXT_dLtc` |
| Cabinet 7 | `cabinet_07` | `pUT4GdBYtW3slhOq` |
All eight tokens are distinct and exactly 16 characters. Copy them exactly, including capitalization, underscores, and hyphens. Cabinet 5's token ends with a hyphen.
## Windows controller and browser
The Python controller does **not** read `arcade_network.cfg`. Merge these values into the existing `tools/launch/launcher.ini`; preserve the current database/catalog paths and other settings:
```ini
[controller]
bind = 0.0.0.0
port = 8766
operator_token = Yu1qH7nz_aMUTlkk
cabinet_tokens = {"cabinet_01":"K32qg0lYioXmzWAK","cabinet_02":"LY9ATvM3wkTxJg0x","cabinet_03":"qZSD9eM2dmeRtmVA","cabinet_04":"2xXcj0sUbOzQCn2j","cabinet_05":"weISFvFBOt6pxfk-","cabinet_06":"h-lP-SfQMXT_dLtc","cabinet_07":"pUT4GdBYtW3slhOq"}
```
Stop the existing stack, then launch controller-only mode so the PC does not compete with a Pi for its cabinet identity:
```powershell
.\Start-Arcade.ps1 -Mode controller -Browser
```
Enter the **Operator** token from the table in the browser. The PC can browse `http://127.0.0.1:8766/`; other devices use `http://WINDOWS_PC_IP:8766/`. No Godot CFG is required for this browser workflow.
If old environment variables override the INI, clear them in the launching PowerShell session before restarting:
```powershell
Remove-Item Env:ARCADE_OPERATOR_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:ARCADE_CABINET_TOKENS -ErrorAction SilentlyContinue
Remove-Item Env:ARCADE_CABINET_TOKEN -ErrorAction SilentlyContinue
```
## Optional Godot operator on the PC
Only when running the exported app with `-- --operator`, create `%APPDATA%/Godot/app_userdata/ArcadeEscape/arcade_network.cfg`:
```ini
[network]
url="http://127.0.0.1:8766"
operator_token="Yu1qH7nz_aMUTlkk"
```
Use the controller's LAN IP instead if it runs on another machine. This CFG supplies the Godot operator client; it does not change the Python server's accepted tokens.
## Pi configuration
Create `~/.local/share/godot/app_userdata/ArcadeEscape/arcade_network.cfg` under the account running the game. It is not stored beside the executable. Replace `WINDOWS_PC_IP` in the examples below with the controller's actual LAN address.
For each Pi, also set the matching ID in `tools/launch/launcher.ini`. The launcher always passes this ID and it overrides the CFG ID. Leave its `url` and `token` empty to use the Godot CFG. Retain your executable path, `gpio` setting, and hardware pin allowlist.
### Cabinet 1
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="K32qg0lYioXmzWAK"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_01
url =
token =
```
### Cabinet 2
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_02"
cabinet_token="LY9ATvM3wkTxJg0x"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_02
url =
token =
```
### Cabinet 3
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_03"
cabinet_token="qZSD9eM2dmeRtmVA"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_03
url =
token =
```
### Cabinet 4
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_04"
cabinet_token="2xXcj0sUbOzQCn2j"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_04
url =
token =
```
### Cabinet 5
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_05"
cabinet_token="weISFvFBOt6pxfk-"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_05
url =
token =
```
### Cabinet 6
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_06"
cabinet_token="h-lP-SfQMXT_dLtc"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_06
url =
token =
```
### Cabinet 7
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_07"
cabinet_token="pUT4GdBYtW3slhOq"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_07
url =
token =
```
## Start and verify each Pi
1. Verify its cabinet profile is registered in `data/project_database.tres`, with its own hardware controller ID and actions. Configure the bridge to match that profile. The current catalog contains Cabinets 1–3; Cabinets 4–7 are reserved token assignments until their profiles are created, registered, exported, and deployed.
2. Run Control Setup on that Pi if not already configured.
3. If using the CFG examples above, clear stale overrides in that Pi's launching terminal:
```sh
unset ARCADE_ROOM_URL ARCADE_CABINET_TOKEN
```
4. Start the cabinet from the deployment folder:
```sh
bash start-arcade.sh cabinet --check
bash start-arcade.sh cabinet
```
5. Confirm that the correct cabinet ID becomes ONLINE in the browser. Run only one process per ID.
Alternatively, put the URL and this cabinet's token directly in `[cabinet]` in `launcher.ini` (unquoted). That overrides the Godot CFG. Existing environment variables have the highest priority. For real GPIO, use the hardware profile's controller ID and BCM pin allowlist in `[gpio]`; the bridge remains local on 8765 while the game connects to the room controller on 8766.
## Generate additional tokens
Windows PowerShell:
```powershell
py -3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
Pi / macOS / Linux:
```sh
python3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
Each generates exactly 16 characters. Use a different value for each identity. To replace a cabinet token, change the controller's JSON entry and that cabinet's configured token together, update this registry, and restart both affected processes. Operator token changes also require a controller restart and a fresh browser login.
See the [project README](../README.md), [launcher guide](../tools/launch/README.md), and [controller guide](../tools/room_controller/README.md) for the full deployment workflow.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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())
+1
View File
@@ -0,0 +1 @@
__pycache__/
+83
View File
@@ -0,0 +1,83 @@
# 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)
For a typical small red LED:
```text
physical pin 11 (BCM GPIO17) -> 330–470 ohm resistor -> LED anode (long leg)
LED cathode (short leg / flat side) -> physical pin 6 (GND)
```
Keep the LED's legs in different breadboard rows. The resistor must be in series, not bypassed by another wire. Physical pin **17 is 3.3V power**, not GPIO17. Do not use the 5V rail for this test. GPIO is 3.3V logic; coin dispensers, relay coils and other powered loads require an appropriate driver, not a direct GPIO connection.
Pinout and LED reference: [GPIO Zero recipes](https://gpiozero.readthedocs.io/en/stable/recipes.html#pin-numbering).
## 2. Install dependencies on the Pi
Use a current Raspberry Pi OS installation and its packaged GPIO Zero/lgpio support (Pi 4 or Pi 5):
```sh
sudo apt update
sudo apt install python3-gpiozero python3-lgpio
```
The account running the helper needs access to `/dev/gpiochip*`. Raspberry Pi OS normally provides this via the `gpio` group. If you get a permission error, inspect `ls -l /dev/gpiochip*` and `groups`; if needed, run `sudo usermod -aG gpio "$USER"`, then log out and back in. The game itself does not need to run as root.
## 3. Test the wiring independently
Before running the bridge, run this on the Pi. The LED should remain lit for two seconds, then turn off:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 -c 'from gpiozero import LED; from time import sleep; led=LED(17); led.on(); sleep(2); led.off(); led.close()'
```
If this fails, fix the reported GPIO permissions/package error or check the resistor, LED orientation, breadboard connections and pin numbering before testing the game. A successful command without light does not prove the wiring is correct.
## 4. Start the GPIO helper
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
```
It listens only on `127.0.0.1:8765` and owns the allowed pins. Do not run another LED script at the same time. It accepts only the configured controller ID and listed BCM pins. Add `,20` to the pin list later if you wire the configured Start Light as well. Do not enable other pins without checking their wiring.
## 5. Run the exported game with real outputs
In another terminal, replacing `ArcadeEscape.arm64` with your actual executable filename:
```sh
./ArcadeEscape.arm64 -- --gpio=real --gpio-test
```
`--gpio-test` sends a two-second pulse on cabinet 01's coin-release output (BCM17) at startup. No game win is required. The launcher shows GPIO status and the helper terminal logs each accepted command. Once that works, omit the test flag:
```sh
./ArcadeEscape.arm64 -- --gpio=real
```
Choose Pac-Man and win its configured objective. The launcher's **Hardware Scenario** defaults to `proof_of_concept.tres`: `game_won` -> cabinet 01 `coin_release` -> BCM17, active-high, **600 milliseconds**. This is only a short flash. Other launcher games only send hardware events if they have a matching assignment in the selected Hardware Scenario. Add their event mappings in your resources as needed.
Without `--gpio=real`, runtime outputs remain simulated, including on Windows. Real mode does not silently fall back to simulation if the helper fails. Connection failures, wrong-controller requests and rejected pins appear in the launcher status/log. The editor's room simulation should be run without real mode. This bridge controls the Pi running the game, not GPIO on a different Pi across the network.
## Timing and stopping
Managed room rewards include a unique `request_id`. The updated helper records these in `~/.local/state/arcade_gpio/requests.sqlite3` (override with `--ledger`). Repeated IDs return the earlier result without driving the pin again. An interrupted request with no recorded outcome is marked uncertain and will not be replayed. Keep this ledger across restarts; standalone requests without an ID retain the original retrigger behavior.
The helper controls pulse timing; repeated pulses restart that pin's duration. Digital outputs accept `context.active=false` to switch off. Active-low polarity is supported by GPIO Zero. Conflicting polarity on the same pin is rejected until the helper restarts.
The game sends a heartbeat every second. If it exits or disconnects, outputs become inactive within roughly three seconds; short pulses still finish at their own deadline. Ctrl+C/SIGTERM shuts down the helper and deactivates its outputs. This is a software watchdog, not a hardware safety circuit; power loss or forcibly killing the helper cannot guarantee pin state. Test with the LED before attaching real actuators.
Keep the helper running while restarting games with Escape. For unattended boot, launch the helper under your Pi's service manager using the same account/environment and start the game with `--gpio=real`.
## Validation performed off-Pi
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Local Godot -> gpiozero bridge. BCM pins; run on the Pi, not the PC."""
import argparse
import json
import signal
import threading
import time
import hashlib
import sqlite3
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
class Outputs:
def __init__(self, factory, controller, allowed, clock=time.monotonic, ledger=":memory:"):
self.factory, self.controller, self.allowed = factory, controller, set(allowed)
self.clock = clock
self.last_seen = clock()
self.pins = {}
self.lock = threading.RLock()
self.ledger = sqlite3.connect(ledger, check_same_thread=False)
self.ledger.execute("CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT)")
def heartbeat(self):
with self.lock:
self.last_seen = self.clock()
def command(self, data):
if not isinstance(data, dict): raise ValueError("Expected a JSON object")
request_id = data.get("request_id")
if request_id is None: return self._apply_command(data)
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 100:
raise ValueError("Invalid request_id")
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
with self.lock:
row = self.ledger.execute("SELECT digest, result FROM requests WHERE id=?", (request_id,)).fetchone()
if row:
if row[0] != digest: raise ValueError("request_id reused with a different output")
return json.loads(row[1]) if row[1] else {"ok": False, "error": "Prior output outcome uncertain; refusing to pulse again"}
# Commit before touching the pin. A crash can miss a reward, but cannot replay it.
with self.ledger:
self.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", (request_id, digest))
try:
result = self._apply_command(data)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
with self.ledger:
self.ledger.execute("UPDATE requests SET result=? WHERE id=?", (json.dumps(result), request_id))
return result
def _apply_command(self, data):
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
if data.get("controller") != self.controller:
raise ValueError("Wrong controller_id for this Pi")
pin = data.get("pin")
if type(pin) is not int or pin not in self.allowed:
raise ValueError("BCM pin is not enabled in the bridge --pins list")
polarity = data.get("active_high")
if type(polarity) is not bool:
raise ValueError("active_high must be boolean")
mode = data.get("mode")
if mode not in ("pulse", "set"):
raise ValueError("mode must be pulse or set")
duration = data.get("duration_ms", 0)
if mode == "pulse" and (type(duration) is not int or not 1 <= duration <= 10000):
raise ValueError("Pulse duration must be 1..10000 ms")
active = data.get("active", True)
if type(active) is not bool:
raise ValueError("active must be boolean")
with self.lock:
if pin not in self.pins:
device = self.factory(pin, active_high=polarity, initial_value=False)
self.pins[pin] = {"device": device, "polarity": polarity, "deadline": None}
entry = self.pins[pin]
if entry["polarity"] != polarity:
raise ValueError("Pin polarity differs from its earlier configuration; restart bridge to change")
self.last_seen = self.clock()
if mode == "pulse":
entry["device"].on()
# Latest pulse restarts the duration; an older timer cannot switch it off.
entry["deadline"] = self.clock() + duration / 1000
else:
entry["device"].on() if active else entry["device"].off()
entry["deadline"] = None
return {"ok": True, "pin": pin, "active": mode == "pulse" or active}
def tick(self):
with self.lock:
now = self.clock()
for entry in self.pins.values():
if now - self.last_seen > 3 or (entry["deadline"] is not None and now >= entry["deadline"]):
entry["device"].off()
entry["deadline"] = None
def close(self):
with self.lock:
for entry in self.pins.values():
try:
entry["device"].off()
finally:
entry["device"].close()
self.ledger.close()
def handler_for(outputs):
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
if self.headers.get("Origin"):
raise ValueError("Browser requests are not supported")
length = int(self.headers.get("Content-Length", "0"))
if not 0 <= length <= 4096:
raise ValueError("Request is too large")
data = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/heartbeat":
outputs.heartbeat()
result = {"ok": True}
elif self.path == "/output":
result = outputs.command(data)
print("GPIO:", data, flush=True)
else:
raise ValueError("Unknown endpoint")
code = 200
except Exception as exc:
result, code = {"ok": False, "error": str(exc)}, 400
print("GPIO ERROR:", exc, flush=True)
body = json.dumps(result).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--controller", default="cabinet_01_gpio")
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
args = parser.parse_args()
pins = [int(n) for n in args.pins.split(",")]
if not pins or any(pin < 0 or pin > 27 for pin in pins):
parser.error("Use BCM header GPIO numbers 0..27")
from gpiozero import OutputDevice
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
server.timeout = .05
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
signal.signal(signal.SIGINT, lambda *_: stop.set())
def watchdog():
while not stop.wait(.02):
outputs.tick()
worker = threading.Thread(target=watchdog, daemon=True)
worker.start()
print(f"GPIO bridge ready: controller={args.controller}, BCM pins={pins}, localhost:8765", flush=True)
try:
while not stop.is_set():
server.handle_request()
finally:
stop.set()
worker.join()
outputs.close()
server.server_close()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
__pycache__/
*.sqlite3*
+167
View File
@@ -0,0 +1,167 @@
# Arcade room controller and browser operator
See the [cabinet setup guide](../../docs/cabinet_setup.md) for the supplied internal token registry and PC-versus-Pi configuration examples.
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.
- Room boundary, walls, cabinet positions, and reference device positions exported from the existing editor resources.
- Independent cabinet connections, game sessions, readiness, win/loss states, and command history.
- Scenario preparation across multiple cabinets. The entire assignment list is validated before commands are queued. Each cabinet acknowledges separately; this is not an atomic room-wide load.
- Compatible game lists and server-side and cabinet-side hardware checks. Games load from the cabinet's installed export; this does not transfer game binaries.
- Designer event mappings for `game_activated`, `game_started`, `game_won`, and `game_lost`. Other event mappings and nonstandard variants are rejected instead of silently ignored.
- Manual game assignments without scenario hardware rewards.
- Idempotent GPIO commands through the existing local bridge. Accepted commands do not prove a coin was delivered or a physical device changed.
External sensor inputs, detailed designer-defined puzzle steps, room timers/hints, and coordinated finale game variants are future work. The map's device dots are reference positions, not live sensor indicators. Runtime phases and hardware command acknowledgements are live.
## Deployment layout
```text
Mac mini / PC: Python controller :8766 + persistent SQLite database
^ browser operator (Mac / iPad, same controller URL)
^ managed Godot cabinet clients (one identity per Pi)
-> local GPIO bridge 127.0.0.1:8765 -> cabinet hardware
```
Use a trusted isolated room LAN. This HTTP configuration does not encrypt traffic; use TLS termination before using an untrusted network. Do not expose it directly to the internet. The GPIO bridge stays on each Pi's loopback address, not the controller address.
## 1. Export the designer catalog
The source of truth remains the existing Godot resources:
- `data/project_database.tres`: registered cabinets, games, and scenarios.
- `data/rooms/arcade_room.tres`: room layout and cabinet/device placements.
- `data/cabinets/*.tres`: cabinet IDs, capabilities, and hardware actions.
- `data/games/*.tres`: requirements and alternative control sets.
After saving designer changes, run this on the development computer (replace `godot` with your Godot executable):
```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
This writes `tools/room_controller/catalog.json`. Copy **server.py, operator.html, and catalog.json together** onto the controller machine. Restart the controller after updating the catalog. Re-export/redeploy Godot when game, cabinet, or hardware resources change; its own compatibility check uses those packaged resources.
Capabilities describe physical controls; they do not replace the per-device control mapping wizard. Configure both. Common requirements must all be present; when a game has control alternatives, one entire alternative must also be present. Tempest accepts horizontal joystick, spinner, or trackball plus two buttons and Start. Robotron accepts dual 8-way joysticks or one 8-way joystick plus four firing buttons, with Start. Missile Command requires trackball, three fire buttons, and Start.
The sample cabinet profiles are intentionally not expanded to claim hardware they may not have. Cabinet 01/02 currently list one action button; Cabinet 03 has no room placement. Update these resources to match the physical cabinets before expecting all seven games to appear in their compatible lists.
## 2. Start the controller on the Mac mini (or PC)
Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. The assigned tokens for this internal installation are recorded in the cabinet setup guide.
macOS / Linux:
```sh
export ARCADE_OPERATOR_TOKEN='your-generated-operator-token'
export ARCADE_CABINET_TOKENS='{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
python3 server.py --bind 0.0.0.0 --database /absolute/path/arcade_room.sqlite3
```
Windows PowerShell, from the project directory:
```powershell
$env:ARCADE_OPERATOR_TOKEN = 'your-generated-operator-token'
$env:ARCADE_CABINET_TOKENS = '{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3
```
The existing `ARCADE_CABINET_TOKEN` environment variable still provisions `cabinet_01` if that ID is not already in the JSON object. Cabinets without provisioned tokens cannot connect. Each cabinet token is bound to its ID and cannot control other cabinets or use the operator API.
Keep the database path stable. Allow inbound TCP 8766 on the room LAN. Existing single-cabinet state is migrated into cabinet 01's record; do not delete the database to upgrade. Back it up before deployment. Connectivity always starts offline until a cabinet polls again.
## 3. Start each Pi cabinet
Deploy the updated Godot export: the fleet controller requires protocol 2 (`arcade-cabinet-2`). Old cabinet exports are rejected so they cannot incorrectly load Pac-Man for a different assignment.
First run the normal launcher to map that Pi's physical controls. The managed client uses the saved mappings. Escape still quits.
Configure `user://arcade_network.cfg` on each Pi:
```ini
[network]
url="http://CONTROLLER_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="your-generated-cabinet-one-token"
```
Godot CFG strings use double quotes. The file belongs in the app's user data folder, not beside the executable: normally `~/.local/share/godot/app_userdata/ArcadeEscape/` on Linux and `%APPDATA%/Godot/app_userdata/ArcadeEscape/` on Windows. URL/token environment variables override the file. `--cabinet=cabinet_02` overrides the cabinet ID.
Start the [GPIO bridge](../pi_gpio/README.md) on each Pi with that cabinet's controller ID and configured BCM pin allowlist. For the Cabinet 01 coin-release LED test:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 --gpio=real
```
Run those in separate terminals/services. Additional mapped actions require their actual pins in the bridge's allowlist. Do not use the cabinet 01 bridge identity on cabinet 02. Only run one managed game process per cabinet ID. The GPIO helper uses port **8765 locally**, while the room URL uses **8766**.
For desktop game/network testing, omit `--gpio=real`. Hardware actions report failure in simulation mode; they do not claim physical success.
## 4. Use the operator
Open `http://CONTROLLER_IP:8766/` in the Mac or iPad browser and enter the operator token. No iPad app or Godot installation is needed. The token stays in page memory and is cleared by Lock, reload, or closing the page.
1. Verify the expected cabinets show ONLINE.
2. Select a scenario and **Prepare assigned cabinets**. Each cabinet changes from PREPARING to READY only after its game loads. An offline or incompatible assignment prevents the entire dispatch.
3. Select a cabinet on the map or card, then **Start game**, or use the cabinet's mapped Start button.
4. Watch its runtime phase, hardware command statuses, and activity history. Win outputs follow that scenario's event mappings.
5. **Reset cabinet…** ends its current session. Reset active cabinets before preparing another scenario.
6. For an individual game override, select a compatible game and **Prepare game**. This deliberately removes scenario event mappings for that cabinet; no automatic coin reward is inherited.
The room map uses the saved designer positions. Cabinets without placements remain selectable through their cards. This is an operator view; continue editing layout/scenarios in Godot, then re-export the catalog.
The older exported `--operator` Godot window remains a Cabinet 01 quick-control view. Its Prepare Pac-Man button prepares the `proof_of_concept` scenario and keeps that scenario's reward. Use the browser for multiple cabinets and other game assignments.
## Reconnects and uncertain hardware results
- Cabinets become offline after roughly four seconds without a poll. The browser disables actions when the controller connection is unavailable.
- Commands expire after 15 seconds. Expired commands are not automatically reissued.
- Every command carries its cabinet session and unique ID. Old-session reports are ignored after Reset/Prepare.
- Duplicate command deliveries reuse the agent outcome; the GPIO bridge persists request IDs across helper restarts to prevent replayed pulses.
- A cabinet process restart requires preparation again. A reconnect by the same process can deliver its buffered reports.
- An accepted GPIO command confirms the bridge API call, not physical delivery. A timeout/failure is uncertain; inspect the hardware before trying again. There is no automatic reward retry.
- Keep the GPIO request ledger at its configured path, default `~/.local/state/arcade_gpio/requests.sqlite3`. Do not delete it to replay a command.
- Reset unloads the game and clears the controller session; it is not a physical hardware reset. Existing bridge pulse timers/watchdog still apply.
## Verification
```sh
python3 -m unittest discover -s tests/room_network -v
python3 -m unittest discover -s tests/gpio -v
python3 tests/room_network/check_integration.py /path/to/godot
godot --headless --path . --script tests/room_network/check_capabilities.gd
```
The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports.
Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing.
## Two-Pi chain test: Cabinet 1 wins -> Cabinet 2 starts
Select **Cabinet 1 -> Cabinet 2** (`cabinet_chain_test`) in the browser. This separate sample uses Pac-Man on `cabinet_01` followed by Galaga on `cabinet_02`, with no GPIO event mappings required.
1. On Windows, run the controller only: `.\Start-Arcade.ps1 -Mode controller -Browser`. Do not also start a Windows game using either Pi's cabinet identity.
2. Provision distinct cabinet tokens for `cabinet_01` and `cabinet_02` in the controller configuration.
3. On Pi 1 set `[cabinet] id = cabinet_01`; on Pi 2 set `id = cabinet_02`. Use each cabinet's matching token and the Windows controller URL, then run `bash start-arcade.sh cabinet` on both.
4. Confirm both cabinets are ONLINE. Reset any active assignments, select **Cabinet 1 -> Cabinet 2**, and click **Prepare assigned cabinets**.
5. Cabinet 1 becomes READY with Pac-Man. Cabinet 2 unloads any game, then shows WAITING in the operator; the Pi shows its existing waiting-for-operator screen. Its physical Start button cannot bypass the dependency because no game is loaded.
6. Start Cabinet 1 and win its configured Pac-Man objective. Cabinet 2 automatically loads Galaga, waits for the game's readiness acknowledgement, and starts it. No operator click or physical Start on Cabinet 2 is needed.
To repeat, reset Cabinet 1 and Cabinet 2, then prepare the scenario again. Resetting/reassigning/restarting a source cabinet invalidates its dependent sessions, including any running dependent game; prepare the scenario again. A loss does not activate the next cabinet. Duplicate win reports and acknowledgements do not create additional starts. An offline target waits until the same process reconnects; a restarted target or a failed/expired load requires operator preparation again.
The activation field is `activate_after_win` on a `ScenarioAssignment` resource (editable in the Inspector). Set it to another cabinet ID in the same scenario, or leave it empty for normal immediate preparation. Export the catalog after editing it. Missing dependencies and cycles are rejected before dispatch. This activation behavior belongs to the managed network controller; the local editor simulation does not execute this network chain.
For this update, deploy **server.py, catalog.json, and operator.html** together and restart the Windows controller, then refresh the browser. Existing protocol-2 Pi game exports already support the reset/prepare/start commands used here; a Pi game re-export is not required solely for this controller feature.
Automated two-client check (no GPIO; wins forced only by the test harness):
```sh
python3 tests/room_network/check_chain_integration.py /path/to/godot
```
+338
View File
@@ -0,0 +1,338 @@
{
"cabinets": {
"cabinet_01": {
"actions": [
"coin_release",
"start_light"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal",
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
],
"name": "Master Cabinet",
"position": [
-5.94849967956543,
0.147500038146973
]
},
"cabinet_02": {
"actions": [
"coin_release",
"start_light",
"glitch_code"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal"
],
"name": "Cabinet 02",
"position": [
-3.51539182662964,
0.142745211720467
]
},
"cabinet_03": {
"actions": [
"coin_release"
],
"capabilities": [
"spinner",
"button_1",
"start_button"
],
"name": "Cabinet 03",
"position": null
}
},
"games": {
"frogger": {
"alternatives": [],
"name": "Frogger",
"requirements": [
"joystick_4way",
"start_button"
]
},
"galaga": {
"alternatives": [],
"name": "Galaga",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"missile_command": {
"alternatives": [],
"name": "Missile Command",
"requirements": [
"trackball",
"button_1",
"button_2",
"button_3",
"start_button"
]
},
"pacman": {
"alternatives": [],
"name": "Pac-Man",
"requirements": [
"joystick_4way",
"start_button"
]
},
"robotron": {
"alternatives": [
[
"dual_joystick_8way"
],
[
"joystick_8way",
"button_1",
"button_2",
"button_3",
"button_4"
]
],
"name": "Robotron 2084",
"requirements": [
"start_button"
]
},
"space_invaders": {
"alternatives": [],
"name": "Space Invaders",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"tempest": {
"alternatives": [
[
"joystick_horizontal"
],
[
"spinner"
],
[
"trackball"
]
],
"name": "Tempest",
"requirements": [
"button_1",
"button_2",
"start_button"
]
}
},
"layout": {
"boundary": [
[
-7.31519985198975,
-0.609600007534027
],
[
-2.13359999656677,
-0.609600007534027
],
[
-2.13359999656677,
7.61999988555908
],
[
-7.31519985198975,
7.61999988555908
]
],
"devices": [
{
"id": "coin_release_a",
"name": "Coin Release A",
"position": [
-6.0149998664856,
0.555000007152557
]
},
{
"id": "coin_release_b",
"name": "Coin Release B",
"position": [
-3.57996416091919,
0.568386673927307
]
},
{
"id": "light_a",
"name": "Light A",
"position": [
-6.46999979019165,
0.560000002384186
]
},
{
"id": "light_b",
"name": "Light B",
"position": [
-4.0322380065918,
0.576531648635864
]
},
{
"id": "glitch_code",
"name": "Glitch Code",
"position": [
-2.75500297546387,
1.51234555244446
]
}
],
"walls": [
[
[
-7.01039981842041,
-0.304800003767014
],
[
-2.43840003013611,
-0.304800003767014
]
],
[
[
-2.43840003013611,
-0.304800003767014
],
[
-2.43840003013611,
7.31519985198975
]
],
[
[
-2.43840003013611,
7.31519985198975
],
[
-7.01039981842041,
7.31519985198975
]
],
[
[
-7.01039981842041,
7.31519985198975
],
[
-7.01039981842041,
-0.304800003767014
]
]
]
},
"scenarios": {
"cabinet_chain_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1 -> Cabinet 2"
},
"galaga_proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "galaga",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Galaga Proof of Concept"
},
"proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Proof of Concept"
},
"two_cabinet_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {
"game_activated": [
"glitch_code"
],
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Two Cabinet Test"
}
},
"version": 1
}
@@ -0,0 +1,38 @@
extends SceneTree
## Export designer resources to the controller's deployment catalog.
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var database: ProjectDatabase = load("res://data/project_database.tres")
var room: RoomDefinition = load("res://data/rooms/arcade_room.tres")
var catalog := {"version": 1, "games": {}, "cabinets": {}, "scenarios": {}, "layout": {"boundary": [], "walls": [], "devices": []}}
for game in database.games:
catalog.games[str(game.game_id)] = {"name": game.display_name, "requirements": Array(game.required_capabilities), "alternatives": Array(game.control_alternatives)}
for cabinet in database.cabinets:
var actions: Array = []
if cabinet.hardware_config != null:
for action in cabinet.hardware_config.actions: actions.append(str(action.action_id))
var placement := room.get_cabinet_placement(cabinet.cabinet_id)
catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null}
for scenario in database.scenarios:
var assignments: Array = []
for assignment in scenario.assignments:
if assignment.cabinet == null or assignment.game == null: continue
var mappings := {}
for mapping in assignment.event_mappings:
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions))
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "mappings": mappings})
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments}
for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y])
for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
for device in room.devices:
catalog.layout.devices.append({"id": str(device.device_id), "name": device.display_name, "position": [device.position.x, device.position.y]})
var file := FileAccess.open("res://tools/room_controller/catalog.json", FileAccess.WRITE)
if file == null:
push_error("Cannot write controller catalog")
quit(1)
return
file.store_string(JSON.stringify(catalog, "\t"))
print("Exported controller catalog from editor resources")
quit()
@@ -0,0 +1 @@
uid://bj7g63dhd8r1t
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}}
</style></head><body>
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header>
<main><div id="error" class="panel" role="alert"></div>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form>
<div id="workspace" hidden>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div>
</div></main><script>
'use strict';
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false;
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
</script></body></html>
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""Arcade room controller and browser operator; Python standard library only."""
import argparse
import hmac
import json
import os
from pathlib import Path
import sqlite3
import time
import uuid
from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, HTTPServer
class Room:
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
self.clock = clock
self.cabinet_id = cabinet_id
self.db = sqlite3.connect(database, check_same_thread=False)
self.db.execute("CREATE TABLE IF NOT EXISTS room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
self.db.execute("CREATE TABLE IF NOT EXISTS cabinet_rooms (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
row = self.db.execute("SELECT value FROM cabinet_rooms WHERE id=?", (cabinet_id,)).fetchone()
if row is None and cabinet_id == "cabinet_01":
row = self.db.execute("SELECT value FROM room WHERE id=1").fetchone()
self.state = json.loads(row[0]) if row else dict(
session="", phase="idle", reward="not requested", commands=[], seen=[], history=[])
# Never carry live connectivity across a controller restart.
self.last_seen = 0
self.boot = self.state.get("boot", "")
self.version = "unknown"
self.gpio = "unknown"
self.state.setdefault("game", "pacman")
self.state.setdefault("mappings", {"game_won": ["coin_release"]})
self.save()
def save(self):
with self.db:
self.db.execute("INSERT OR REPLACE INTO cabinet_rooms VALUES (?, ?)", (self.cabinet_id, json.dumps(self.state)))
def log(self, message):
self.state["history"].append(dict(time=self.clock(), message=message))
self.state["history"] = self.state["history"][-100:]
def online(self):
return self.last_seen > 0 and self.clock() - self.last_seen < 4
def snapshot(self):
self.expire()
return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
version=self.version, gpio=self.gpio,
last_seen=self.last_seen, protocol=2)
def expire(self):
for command in self.state["commands"]:
if command["status"] == "queued" and self.clock() > command["expires"]:
command["status"] = "unknown"
if command["type"] in ("reward", "output"):
self.state["reward" if command["type"] == "reward" else "last_output"] = "unknown — inspect hardware; not automatically retried"
else:
self.state["phase"] = "needs_prepare"
self.log(command["type"] + " acknowledgement timed out")
self.save()
def command(self, kind):
command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind,
status="queued", expires=self.clock() + 15)
if kind == "prepare":
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"))
self.state["commands"].append(command)
self.log("Queued " + kind)
return command
def operator(self, action):
self.expire()
if not self.online(): raise ValueError("Cabinet is offline; no command sent")
if any(c["status"] == "queued" and c["type"] not in ("output", "reward") for c in self.state["commands"]):
raise ValueError("Wait for the pending command or its timeout")
if action in ("prepare", "reset"):
if action == "prepare" and self.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
raise ValueError("Reset the active session before preparing again")
self.state.update(session=str(uuid.uuid4()), phase="preparing" if action == "prepare" else "resetting",
reward="not requested", commands=[], seen=[])
self.command(action)
elif action == "start":
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
self.state["phase"] = "starting"
self.command("start")
else:
raise ValueError("Unknown operator action")
self.save()
return self.snapshot()
def poll(self, data):
if data.get("cabinet") != self.cabinet_id or data.get("protocol") not in (1, 2):
raise ValueError("Wrong cabinet or protocol version")
boot = data.get("boot")
if not isinstance(boot, str) or not 1 <= len(boot) <= 100:
raise ValueError("Missing cabinet boot identity")
if self.boot and boot != self.boot:
self.state.update(session="", phase="needs_prepare", commands=[], seen=[], reward="not requested")
self.log("Cabinet restarted; operator must prepare a new session")
self.boot = boot
self.state["boot"] = boot
self.last_seen = self.clock()
self.version = str(data.get("version", "unknown"))[:80]
self.gpio = str(data.get("gpio", "unknown"))[:100]
self.expire()
reports = data.get("reports", [])
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
for report in reports:
if not isinstance(report, dict): raise ValueError("Invalid report")
if report.get("session") != self.state["session"]: continue
if report.get("kind") == "ack":
self.ack(report)
elif report.get("kind") == "event":
self.event(report)
self.save()
return dict(ok=True, session=self.state["session"],
commands=[c for c in self.state["commands"] if c["status"] == "queued"])
def ack(self, report):
command = next((c for c in self.state["commands"] if c["id"] == report.get("id")), None)
if command is None or command["status"] != "queued": return
success = report.get("ok") is True
command["status"] = "accepted" if success else "failed"
self.log("%s: %s" % (command["type"], str(report.get("detail", command["status"]))[:200]))
if command["type"] == "output":
self.state["last_output"] = "accepted (physical state unconfirmed)" if success else "failed or uncertain"
elif command["type"] == "reward":
statuses = [c["status"] for c in self.state["commands"] if c["type"] == "reward"]
if any(s in ("failed", "unknown") for s in statuses):
self.state["reward"] = "failed or uncertain — inspect cabinet"
elif all(s == "accepted" for s in statuses):
self.state["reward"] = "GPIO accepted (physical delivery unconfirmed)"
else:
self.state["reward"] = "requested"
elif success:
self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
if command["type"] == "prepare" and self.state.pop("auto_start", False):
self.state["phase"] = "starting"
self.command("start")
if command["type"] == "prepare":
for action in self.state["mappings"].get("game_activated", []):
self.command("output")["action_id"] = action
else:
self.state["phase"] = "needs_prepare"
def event(self, report):
event_id = report.get("id")
if not isinstance(event_id, str) or not 1 <= len(event_id) <= 100: return
if event_id in self.state["seen"]: return
self.state["seen"].append(event_id)
self.state["seen"] = self.state["seen"][-500:]
name = report.get("event")
if name == "started" and self.state["phase"] in ("ready", "starting"):
self.state["phase"] = "playing"
elif name == "won" and self.state["phase"] == "playing":
self.state["phase"] = "won"
if self.state["reward"] == "not requested" and self.state["mappings"].get("game_won"):
self.state["reward"] = "requested"
elif name == "lost" and self.state["phase"] == "playing":
self.state["phase"] = "lost"
else:
return
self.log(self.state["game"] + " " + name)
for action in self.state["mappings"].get("game_" + name, []):
command = self.command("reward" if name == "won" else "output")
command["action_id"] = action
class Fleet:
"""Editor catalog plus independently acknowledged cabinet sessions."""
def __init__(self, database, catalog, clock=time.time):
self.catalog = catalog
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
def compatible(self, cabinet, game):
definition = self.catalog["games"][game]
capabilities = set(self.catalog["cabinets"][cabinet]["capabilities"])
alternatives = definition.get("alternatives", [])
return set(definition["requirements"]) <= capabilities and (not alternatives or any(set(a) <= capabilities for a in alternatives))
def snapshot(self):
cabinets = {}
for key, room in self.rooms.items():
cabinets[key] = dict(room.snapshot(), definition=self.catalog["cabinets"][key],
compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)])
# Retain the single-cabinet desktop UI response while the browser offers the fleet view.
first = cabinets.get("cabinet_01", next(iter(cabinets.values())))
return dict(first, cabinets=cabinets, catalog=self.catalog)
def validate_assignment(self, item):
cabinet, game = item.get("cabinet"), item.get("game")
if cabinet not in self.rooms or game not in self.catalog["games"]:
raise ValueError("Unknown cabinet or game")
if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met")
if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime")
allowed = set(self.catalog["cabinets"][cabinet]["actions"])
for event, actions in item.get("mappings", {}).items():
if event not in ("game_activated", "game_started", "game_won", "game_lost"):
raise ValueError(f"Scenario event {event} is not implemented in managed runtime")
if not set(actions) <= allowed: raise ValueError("Scenario refers to an unknown hardware action")
room = self.rooms[cabinet]
room.expire()
if not room.online(): raise ValueError(f"{cabinet} is offline")
if room.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
raise ValueError(f"Reset {cabinet} before preparing a new assignment")
if any(c["status"] == "queued" for c in room.state["commands"]):
raise ValueError(f"{cabinet} has a pending command")
def operator(self, action, data=None):
data = data or {}
if action == "prepare_scenario":
scenario_id = data.get("scenario")
scenario = self.catalog["scenarios"].get(scenario_id)
if not scenario: raise ValueError("Unknown scenario")
assignments = scenario["assignments"]
if not assignments or len({a["cabinet"] for a in assignments}) != len(assignments):
raise ValueError("Scenario must have unique, nonempty cabinet assignments")
# Validate the whole plan before sending anything to any cabinet.
for item in assignments: self.validate_assignment(item)
by_id = {a["cabinet"]: a for a in assignments}
for item in assignments:
visited = {item["cabinet"]}
source = item.get("activate_after_win", "")
while source:
if source not in by_id: raise ValueError("Dependency cabinet must be assigned in the same scenario")
if source in visited: raise ValueError("Cabinet activation dependencies cannot form a cycle")
visited.add(source)
source = by_id[source].get("activate_after_win", "")
for item in assignments: self.prepare(item, scenario_id)
# Bind dependencies to this particular run, never an earlier win.
for item in assignments:
source = item.get("activate_after_win")
if source:
room = self.rooms[item["cabinet"]]
room.state["gate"] = dict(cabinet=source, session=self.rooms[source].state["session"])
room.log("Waiting for " + source + " to win")
room.save()
elif action == "prepare":
cabinet = data.get("cabinet", "cabinet_01")
item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={})
self.validate_assignment(item)
# Manual overrides do not inherit a reward from the previous scenario.
self.prepare(item, "manual override")
else:
cabinet = data.get("cabinet", "cabinet_01")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
if action == "start" and self.rooms[cabinet].state.get("gate"):
raise ValueError("This cabinet starts automatically after its dependency wins")
self.rooms[cabinet].operator(action)
if action == "reset":
self.rooms[cabinet].state.pop("gate", None)
self.rooms[cabinet].state.pop("auto_start", None)
self.rooms[cabinet].save()
self.advance()
return self.snapshot()
def prepare(self, item, scenario):
room = self.rooms[item["cabinet"]]
room.state.update(game=item["game"], mappings=item.get("mappings", {}),
variant=item.get("variant", "standard"), scenario=scenario)
room.state.pop("gate", None)
room.state.pop("auto_start", None)
room.operator("reset" if item.get("activate_after_win") else "prepare")
room.log("Assignment from " + scenario)
room.save()
def poll(self, data):
cabinet = data.get("cabinet")
if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
self.rooms[cabinet].poll(data)
self.advance()
room = self.rooms[cabinet]
return dict(ok=True, session=room.state["session"],
commands=[c for c in room.state["commands"] if c["status"] == "queued"])
def advance(self):
# Invalidate descendants when their source is reset/reassigned/restarted.
# Iterate to propagate invalidation through longer chains.
for _ in self.rooms:
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate: continue
source = self.rooms.get(gate["cabinet"])
if source is None or source.state["session"] != gate["session"]:
room.state.update(session=str(uuid.uuid4()), phase="needs_prepare", commands=[], seen=[], gate=None, auto_start=False)
room.log("Dependency session ended; prepare the scenario again")
room.save()
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate or room.state["phase"] != "waiting" or not room.online(): continue
source = self.rooms[gate["cabinet"]]
if source.state["phase"] == "won":
room.state.update(phase="preparing", auto_start=True)
room.command("prepare")
room.log("Activated by " + gate["cabinet"] + " win")
room.save()
def handler_for(room, operator_token, cabinet_token):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/", "/index.html"):
self.respond(404, {"error": "Not found"})
return
body = (Path(__file__).parent / "operator.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.wfile.write(body)
def do_POST(self):
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 <= length <= 65536: raise ValueError("Request too large")
data = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(data, dict): raise ValueError("Expected object")
expected = operator_token
if self.path == "/cabinet/poll":
expected = cabinet_token.get(data.get("cabinet"), "") if isinstance(cabinet_token, dict) else cabinet_token
if not expected:
self.respond(401, {"error": "Cabinet credentials not provisioned"})
return
if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + expected):
self.respond(401, {"error": "Unauthorized"})
return
origin = self.headers.get("Origin")
if origin and urlparse(origin).netloc != self.headers.get("Host"):
raise ValueError("Cross-origin requests are disabled")
if self.path == "/cabinet/poll": result = room.poll(data)
elif self.path == "/operator/state": result = room.snapshot()
elif self.path == "/operator/command":
result = room.operator(data.get("action"), data) if isinstance(room, Fleet) else room.operator(data.get("action"))
else: raise ValueError("Unknown endpoint")
self.respond(200, result)
except (ValueError, TypeError, KeyError) as exc:
self.respond(400, {"error": str(exc)})
def setup(self):
super().setup()
self.connection.settimeout(3)
def respond(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args): pass
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8766)
parser.add_argument("--database", default="arcade_room.sqlite3")
parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json")))
args = parser.parse_args()
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
try:
tokens = json.loads(os.environ.get("ARCADE_CABINET_TOKENS", "{}"))
except ValueError:
parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
if not isinstance(tokens, dict): parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
if cabinet: tokens.setdefault("cabinet_01", cabinet)
if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens):
parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets")
catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8"))
room = Fleet(Path(args.database), catalog)
server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens))
print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True)
try: server.serve_forever()
except KeyboardInterrupt: pass
finally:
server.server_close()
for cabinet_room in room.rooms.values(): cabinet_room.db.close()
if __name__ == "__main__": main()
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
printf '\033c\033]0;%s\a' ArcadeEscape
base_path="$(dirname "$(realpath "$0")")"
"$base_path/ArcadeEscape.arm64" "$@"
+4
View File
@@ -0,0 +1,4 @@
[network]
url="http://10.0.0.57:8766"
cabinet_id="cabinet_01"
cabinet_token="K32qg0lYioXmzWAK"
+4
View File
@@ -0,0 +1,4 @@
[network]
url="http://10.0.0.57:8766"
cabinet_id="cabinet_02"
cabinet_token="LY9ATvM3wkTxJg0x"
+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())
File renamed without changes.
@@ -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
+1
View File
@@ -0,0 +1 @@
__pycache__/
+83
View File
@@ -0,0 +1,83 @@
# 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)
For a typical small red LED:
```text
physical pin 11 (BCM GPIO17) -> 330–470 ohm resistor -> LED anode (long leg)
LED cathode (short leg / flat side) -> physical pin 6 (GND)
```
Keep the LED's legs in different breadboard rows. The resistor must be in series, not bypassed by another wire. Physical pin **17 is 3.3V power**, not GPIO17. Do not use the 5V rail for this test. GPIO is 3.3V logic; coin dispensers, relay coils and other powered loads require an appropriate driver, not a direct GPIO connection.
Pinout and LED reference: [GPIO Zero recipes](https://gpiozero.readthedocs.io/en/stable/recipes.html#pin-numbering).
## 2. Install dependencies on the Pi
Use a current Raspberry Pi OS installation and its packaged GPIO Zero/lgpio support (Pi 4 or Pi 5):
```sh
sudo apt update
sudo apt install python3-gpiozero python3-lgpio
```
The account running the helper needs access to `/dev/gpiochip*`. Raspberry Pi OS normally provides this via the `gpio` group. If you get a permission error, inspect `ls -l /dev/gpiochip*` and `groups`; if needed, run `sudo usermod -aG gpio "$USER"`, then log out and back in. The game itself does not need to run as root.
## 3. Test the wiring independently
Before running the bridge, run this on the Pi. The LED should remain lit for two seconds, then turn off:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 -c 'from gpiozero import LED; from time import sleep; led=LED(17); led.on(); sleep(2); led.off(); led.close()'
```
If this fails, fix the reported GPIO permissions/package error or check the resistor, LED orientation, breadboard connections and pin numbering before testing the game. A successful command without light does not prove the wiring is correct.
## 4. Start the GPIO helper
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
```
It listens only on `127.0.0.1:8765` and owns the allowed pins. Do not run another LED script at the same time. It accepts only the configured controller ID and listed BCM pins. Add `,20` to the pin list later if you wire the configured Start Light as well. Do not enable other pins without checking their wiring.
## 5. Run the exported game with real outputs
In another terminal, replacing `ArcadeEscape.arm64` with your actual executable filename:
```sh
./ArcadeEscape.arm64 -- --gpio=real --gpio-test
```
`--gpio-test` sends a two-second pulse on cabinet 01's coin-release output (BCM17) at startup. No game win is required. The launcher shows GPIO status and the helper terminal logs each accepted command. Once that works, omit the test flag:
```sh
./ArcadeEscape.arm64 -- --gpio=real
```
Choose Pac-Man and win its configured objective. The launcher's **Hardware Scenario** defaults to `proof_of_concept.tres`: `game_won` -> cabinet 01 `coin_release` -> BCM17, active-high, **600 milliseconds**. This is only a short flash. Other launcher games only send hardware events if they have a matching assignment in the selected Hardware Scenario. Add their event mappings in your resources as needed.
Without `--gpio=real`, runtime outputs remain simulated, including on Windows. Real mode does not silently fall back to simulation if the helper fails. Connection failures, wrong-controller requests and rejected pins appear in the launcher status/log. The editor's room simulation should be run without real mode. This bridge controls the Pi running the game, not GPIO on a different Pi across the network.
## Timing and stopping
Managed room rewards include a unique `request_id`. The updated helper records these in `~/.local/state/arcade_gpio/requests.sqlite3` (override with `--ledger`). Repeated IDs return the earlier result without driving the pin again. An interrupted request with no recorded outcome is marked uncertain and will not be replayed. Keep this ledger across restarts; standalone requests without an ID retain the original retrigger behavior.
The helper controls pulse timing; repeated pulses restart that pin's duration. Digital outputs accept `context.active=false` to switch off. Active-low polarity is supported by GPIO Zero. Conflicting polarity on the same pin is rejected until the helper restarts.
The game sends a heartbeat every second. If it exits or disconnects, outputs become inactive within roughly three seconds; short pulses still finish at their own deadline. Ctrl+C/SIGTERM shuts down the helper and deactivates its outputs. This is a software watchdog, not a hardware safety circuit; power loss or forcibly killing the helper cannot guarantee pin state. Test with the LED before attaching real actuators.
Keep the helper running while restarting games with Escape. For unattended boot, launch the helper under your Pi's service manager using the same account/environment and start the game with `--gpio=real`.
## Validation performed off-Pi
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Local Godot -> gpiozero bridge. BCM pins; run on the Pi, not the PC."""
import argparse
import json
import signal
import threading
import time
import hashlib
import sqlite3
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
class Outputs:
def __init__(self, factory, controller, allowed, clock=time.monotonic, ledger=":memory:"):
self.factory, self.controller, self.allowed = factory, controller, set(allowed)
self.clock = clock
self.last_seen = clock()
self.pins = {}
self.lock = threading.RLock()
self.ledger = sqlite3.connect(ledger, check_same_thread=False)
self.ledger.execute("CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT)")
def heartbeat(self):
with self.lock:
self.last_seen = self.clock()
def command(self, data):
if not isinstance(data, dict): raise ValueError("Expected a JSON object")
request_id = data.get("request_id")
if request_id is None: return self._apply_command(data)
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 100:
raise ValueError("Invalid request_id")
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
with self.lock:
row = self.ledger.execute("SELECT digest, result FROM requests WHERE id=?", (request_id,)).fetchone()
if row:
if row[0] != digest: raise ValueError("request_id reused with a different output")
return json.loads(row[1]) if row[1] else {"ok": False, "error": "Prior output outcome uncertain; refusing to pulse again"}
# Commit before touching the pin. A crash can miss a reward, but cannot replay it.
with self.ledger:
self.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", (request_id, digest))
try:
result = self._apply_command(data)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
with self.ledger:
self.ledger.execute("UPDATE requests SET result=? WHERE id=?", (json.dumps(result), request_id))
return result
def _apply_command(self, data):
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
if data.get("controller") != self.controller:
raise ValueError("Wrong controller_id for this Pi")
pin = data.get("pin")
if type(pin) is not int or pin not in self.allowed:
raise ValueError("BCM pin is not enabled in the bridge --pins list")
polarity = data.get("active_high")
if type(polarity) is not bool:
raise ValueError("active_high must be boolean")
mode = data.get("mode")
if mode not in ("pulse", "set"):
raise ValueError("mode must be pulse or set")
duration = data.get("duration_ms", 0)
if mode == "pulse" and (type(duration) is not int or not 1 <= duration <= 10000):
raise ValueError("Pulse duration must be 1..10000 ms")
active = data.get("active", True)
if type(active) is not bool:
raise ValueError("active must be boolean")
with self.lock:
if pin not in self.pins:
device = self.factory(pin, active_high=polarity, initial_value=False)
self.pins[pin] = {"device": device, "polarity": polarity, "deadline": None}
entry = self.pins[pin]
if entry["polarity"] != polarity:
raise ValueError("Pin polarity differs from its earlier configuration; restart bridge to change")
self.last_seen = self.clock()
if mode == "pulse":
entry["device"].on()
# Latest pulse restarts the duration; an older timer cannot switch it off.
entry["deadline"] = self.clock() + duration / 1000
else:
entry["device"].on() if active else entry["device"].off()
entry["deadline"] = None
return {"ok": True, "pin": pin, "active": mode == "pulse" or active}
def tick(self):
with self.lock:
now = self.clock()
for entry in self.pins.values():
if now - self.last_seen > 3 or (entry["deadline"] is not None and now >= entry["deadline"]):
entry["device"].off()
entry["deadline"] = None
def close(self):
with self.lock:
for entry in self.pins.values():
try:
entry["device"].off()
finally:
entry["device"].close()
self.ledger.close()
def handler_for(outputs):
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
if self.headers.get("Origin"):
raise ValueError("Browser requests are not supported")
length = int(self.headers.get("Content-Length", "0"))
if not 0 <= length <= 4096:
raise ValueError("Request is too large")
data = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/heartbeat":
outputs.heartbeat()
result = {"ok": True}
elif self.path == "/output":
result = outputs.command(data)
print("GPIO:", data, flush=True)
else:
raise ValueError("Unknown endpoint")
code = 200
except Exception as exc:
result, code = {"ok": False, "error": str(exc)}, 400
print("GPIO ERROR:", exc, flush=True)
body = json.dumps(result).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--controller", default="cabinet_01_gpio")
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
args = parser.parse_args()
pins = [int(n) for n in args.pins.split(",")]
if not pins or any(pin < 0 or pin > 27 for pin in pins):
parser.error("Use BCM header GPIO numbers 0..27")
from gpiozero import OutputDevice
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
server.timeout = .05
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
signal.signal(signal.SIGINT, lambda *_: stop.set())
def watchdog():
while not stop.wait(.02):
outputs.tick()
worker = threading.Thread(target=watchdog, daemon=True)
worker.start()
print(f"GPIO bridge ready: controller={args.controller}, BCM pins={pins}, localhost:8765", flush=True)
try:
while not stop.is_set():
server.handle_request()
finally:
stop.set()
worker.join()
outputs.close()
server.server_close()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
__pycache__/
*.sqlite3*
+167
View File
@@ -0,0 +1,167 @@
# Arcade room controller and browser operator
See the [cabinet setup guide](../../docs/cabinet_setup.md) for the supplied internal token registry and PC-versus-Pi configuration examples.
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.
- Room boundary, walls, cabinet positions, and reference device positions exported from the existing editor resources.
- Independent cabinet connections, game sessions, readiness, win/loss states, and command history.
- Scenario preparation across multiple cabinets. The entire assignment list is validated before commands are queued. Each cabinet acknowledges separately; this is not an atomic room-wide load.
- Compatible game lists and server-side and cabinet-side hardware checks. Games load from the cabinet's installed export; this does not transfer game binaries.
- Designer event mappings for `game_activated`, `game_started`, `game_won`, and `game_lost`. Other event mappings and nonstandard variants are rejected instead of silently ignored.
- Manual game assignments without scenario hardware rewards.
- Idempotent GPIO commands through the existing local bridge. Accepted commands do not prove a coin was delivered or a physical device changed.
External sensor inputs, detailed designer-defined puzzle steps, room timers/hints, and coordinated finale game variants are future work. The map's device dots are reference positions, not live sensor indicators. Runtime phases and hardware command acknowledgements are live.
## Deployment layout
```text
Mac mini / PC: Python controller :8766 + persistent SQLite database
^ browser operator (Mac / iPad, same controller URL)
^ managed Godot cabinet clients (one identity per Pi)
-> local GPIO bridge 127.0.0.1:8765 -> cabinet hardware
```
Use a trusted isolated room LAN. This HTTP configuration does not encrypt traffic; use TLS termination before using an untrusted network. Do not expose it directly to the internet. The GPIO bridge stays on each Pi's loopback address, not the controller address.
## 1. Export the designer catalog
The source of truth remains the existing Godot resources:
- `data/project_database.tres`: registered cabinets, games, and scenarios.
- `data/rooms/arcade_room.tres`: room layout and cabinet/device placements.
- `data/cabinets/*.tres`: cabinet IDs, capabilities, and hardware actions.
- `data/games/*.tres`: requirements and alternative control sets.
After saving designer changes, run this on the development computer (replace `godot` with your Godot executable):
```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
This writes `tools/room_controller/catalog.json`. Copy **server.py, operator.html, and catalog.json together** onto the controller machine. Restart the controller after updating the catalog. Re-export/redeploy Godot when game, cabinet, or hardware resources change; its own compatibility check uses those packaged resources.
Capabilities describe physical controls; they do not replace the per-device control mapping wizard. Configure both. Common requirements must all be present; when a game has control alternatives, one entire alternative must also be present. Tempest accepts horizontal joystick, spinner, or trackball plus two buttons and Start. Robotron accepts dual 8-way joysticks or one 8-way joystick plus four firing buttons, with Start. Missile Command requires trackball, three fire buttons, and Start.
The sample cabinet profiles are intentionally not expanded to claim hardware they may not have. Cabinet 01/02 currently list one action button; Cabinet 03 has no room placement. Update these resources to match the physical cabinets before expecting all seven games to appear in their compatible lists.
## 2. Start the controller on the Mac mini (or PC)
Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. The assigned tokens for this internal installation are recorded in the cabinet setup guide.
macOS / Linux:
```sh
export ARCADE_OPERATOR_TOKEN='your-generated-operator-token'
export ARCADE_CABINET_TOKENS='{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
python3 server.py --bind 0.0.0.0 --database /absolute/path/arcade_room.sqlite3
```
Windows PowerShell, from the project directory:
```powershell
$env:ARCADE_OPERATOR_TOKEN = 'your-generated-operator-token'
$env:ARCADE_CABINET_TOKENS = '{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3
```
The existing `ARCADE_CABINET_TOKEN` environment variable still provisions `cabinet_01` if that ID is not already in the JSON object. Cabinets without provisioned tokens cannot connect. Each cabinet token is bound to its ID and cannot control other cabinets or use the operator API.
Keep the database path stable. Allow inbound TCP 8766 on the room LAN. Existing single-cabinet state is migrated into cabinet 01's record; do not delete the database to upgrade. Back it up before deployment. Connectivity always starts offline until a cabinet polls again.
## 3. Start each Pi cabinet
Deploy the updated Godot export: the fleet controller requires protocol 2 (`arcade-cabinet-2`). Old cabinet exports are rejected so they cannot incorrectly load Pac-Man for a different assignment.
First run the normal launcher to map that Pi's physical controls. The managed client uses the saved mappings. Escape still quits.
Configure `user://arcade_network.cfg` on each Pi:
```ini
[network]
url="http://CONTROLLER_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="your-generated-cabinet-one-token"
```
Godot CFG strings use double quotes. The file belongs in the app's user data folder, not beside the executable: normally `~/.local/share/godot/app_userdata/ArcadeEscape/` on Linux and `%APPDATA%/Godot/app_userdata/ArcadeEscape/` on Windows. URL/token environment variables override the file. `--cabinet=cabinet_02` overrides the cabinet ID.
Start the [GPIO bridge](../pi_gpio/README.md) on each Pi with that cabinet's controller ID and configured BCM pin allowlist. For the Cabinet 01 coin-release LED test:
```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 --gpio=real
```
Run those in separate terminals/services. Additional mapped actions require their actual pins in the bridge's allowlist. Do not use the cabinet 01 bridge identity on cabinet 02. Only run one managed game process per cabinet ID. The GPIO helper uses port **8765 locally**, while the room URL uses **8766**.
For desktop game/network testing, omit `--gpio=real`. Hardware actions report failure in simulation mode; they do not claim physical success.
## 4. Use the operator
Open `http://CONTROLLER_IP:8766/` in the Mac or iPad browser and enter the operator token. No iPad app or Godot installation is needed. The token stays in page memory and is cleared by Lock, reload, or closing the page.
1. Verify the expected cabinets show ONLINE.
2. Select a scenario and **Prepare assigned cabinets**. Each cabinet changes from PREPARING to READY only after its game loads. An offline or incompatible assignment prevents the entire dispatch.
3. Select a cabinet on the map or card, then **Start game**, or use the cabinet's mapped Start button.
4. Watch its runtime phase, hardware command statuses, and activity history. Win outputs follow that scenario's event mappings.
5. **Reset cabinet…** ends its current session. Reset active cabinets before preparing another scenario.
6. For an individual game override, select a compatible game and **Prepare game**. This deliberately removes scenario event mappings for that cabinet; no automatic coin reward is inherited.
The room map uses the saved designer positions. Cabinets without placements remain selectable through their cards. This is an operator view; continue editing layout/scenarios in Godot, then re-export the catalog.
The older exported `--operator` Godot window remains a Cabinet 01 quick-control view. Its Prepare Pac-Man button prepares the `proof_of_concept` scenario and keeps that scenario's reward. Use the browser for multiple cabinets and other game assignments.
## Reconnects and uncertain hardware results
- Cabinets become offline after roughly four seconds without a poll. The browser disables actions when the controller connection is unavailable.
- Commands expire after 15 seconds. Expired commands are not automatically reissued.
- Every command carries its cabinet session and unique ID. Old-session reports are ignored after Reset/Prepare.
- Duplicate command deliveries reuse the agent outcome; the GPIO bridge persists request IDs across helper restarts to prevent replayed pulses.
- A cabinet process restart requires preparation again. A reconnect by the same process can deliver its buffered reports.
- An accepted GPIO command confirms the bridge API call, not physical delivery. A timeout/failure is uncertain; inspect the hardware before trying again. There is no automatic reward retry.
- Keep the GPIO request ledger at its configured path, default `~/.local/state/arcade_gpio/requests.sqlite3`. Do not delete it to replay a command.
- Reset unloads the game and clears the controller session; it is not a physical hardware reset. Existing bridge pulse timers/watchdog still apply.
## Verification
```sh
python3 -m unittest discover -s tests/room_network -v
python3 -m unittest discover -s tests/gpio -v
python3 tests/room_network/check_integration.py /path/to/godot
godot --headless --path . --script tests/room_network/check_capabilities.gd
```
The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports.
Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing.
## Two-Pi chain test: Cabinet 1 wins -> Cabinet 2 starts
Select **Cabinet 1 -> Cabinet 2** (`cabinet_chain_test`) in the browser. This separate sample uses Pac-Man on `cabinet_01` followed by Galaga on `cabinet_02`, with no GPIO event mappings required.
1. On Windows, run the controller only: `.\Start-Arcade.ps1 -Mode controller -Browser`. Do not also start a Windows game using either Pi's cabinet identity.
2. Provision distinct cabinet tokens for `cabinet_01` and `cabinet_02` in the controller configuration.
3. On Pi 1 set `[cabinet] id = cabinet_01`; on Pi 2 set `id = cabinet_02`. Use each cabinet's matching token and the Windows controller URL, then run `bash start-arcade.sh cabinet` on both.
4. Confirm both cabinets are ONLINE. Reset any active assignments, select **Cabinet 1 -> Cabinet 2**, and click **Prepare assigned cabinets**.
5. Cabinet 1 becomes READY with Pac-Man. Cabinet 2 unloads any game, then shows WAITING in the operator; the Pi shows its existing waiting-for-operator screen. Its physical Start button cannot bypass the dependency because no game is loaded.
6. Start Cabinet 1 and win its configured Pac-Man objective. Cabinet 2 automatically loads Galaga, waits for the game's readiness acknowledgement, and starts it. No operator click or physical Start on Cabinet 2 is needed.
To repeat, reset Cabinet 1 and Cabinet 2, then prepare the scenario again. Resetting/reassigning/restarting a source cabinet invalidates its dependent sessions, including any running dependent game; prepare the scenario again. A loss does not activate the next cabinet. Duplicate win reports and acknowledgements do not create additional starts. An offline target waits until the same process reconnects; a restarted target or a failed/expired load requires operator preparation again.
The activation field is `activate_after_win` on a `ScenarioAssignment` resource (editable in the Inspector). Set it to another cabinet ID in the same scenario, or leave it empty for normal immediate preparation. Export the catalog after editing it. Missing dependencies and cycles are rejected before dispatch. This activation behavior belongs to the managed network controller; the local editor simulation does not execute this network chain.
For this update, deploy **server.py, catalog.json, and operator.html** together and restart the Windows controller, then refresh the browser. Existing protocol-2 Pi game exports already support the reset/prepare/start commands used here; a Pi game re-export is not required solely for this controller feature.
Automated two-client check (no GPIO; wins forced only by the test harness):
```sh
python3 tests/room_network/check_chain_integration.py /path/to/godot
```
+338
View File
@@ -0,0 +1,338 @@
{
"cabinets": {
"cabinet_01": {
"actions": [
"coin_release",
"start_light"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal",
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
],
"name": "Master Cabinet",
"position": [
-5.94849967956543,
0.147500038146973
]
},
"cabinet_02": {
"actions": [
"coin_release",
"start_light",
"glitch_code"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal"
],
"name": "Cabinet 02",
"position": [
-3.51539182662964,
0.142745211720467
]
},
"cabinet_03": {
"actions": [
"coin_release"
],
"capabilities": [
"spinner",
"button_1",
"start_button"
],
"name": "Cabinet 03",
"position": null
}
},
"games": {
"frogger": {
"alternatives": [],
"name": "Frogger",
"requirements": [
"joystick_4way",
"start_button"
]
},
"galaga": {
"alternatives": [],
"name": "Galaga",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"missile_command": {
"alternatives": [],
"name": "Missile Command",
"requirements": [
"trackball",
"button_1",
"button_2",
"button_3",
"start_button"
]
},
"pacman": {
"alternatives": [],
"name": "Pac-Man",
"requirements": [
"joystick_4way",
"start_button"
]
},
"robotron": {
"alternatives": [
[
"dual_joystick_8way"
],
[
"joystick_8way",
"button_1",
"button_2",
"button_3",
"button_4"
]
],
"name": "Robotron 2084",
"requirements": [
"start_button"
]
},
"space_invaders": {
"alternatives": [],
"name": "Space Invaders",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"tempest": {
"alternatives": [
[
"joystick_horizontal"
],
[
"spinner"
],
[
"trackball"
]
],
"name": "Tempest",
"requirements": [
"button_1",
"button_2",
"start_button"
]
}
},
"layout": {
"boundary": [
[
-7.31519985198975,
-0.609600007534027
],
[
-2.13359999656677,
-0.609600007534027
],
[
-2.13359999656677,
7.61999988555908
],
[
-7.31519985198975,
7.61999988555908
]
],
"devices": [
{
"id": "coin_release_a",
"name": "Coin Release A",
"position": [
-6.0149998664856,
0.555000007152557
]
},
{
"id": "coin_release_b",
"name": "Coin Release B",
"position": [
-3.57996416091919,
0.568386673927307
]
},
{
"id": "light_a",
"name": "Light A",
"position": [
-6.46999979019165,
0.560000002384186
]
},
{
"id": "light_b",
"name": "Light B",
"position": [
-4.0322380065918,
0.576531648635864
]
},
{
"id": "glitch_code",
"name": "Glitch Code",
"position": [
-2.75500297546387,
1.51234555244446
]
}
],
"walls": [
[
[
-7.01039981842041,
-0.304800003767014
],
[
-2.43840003013611,
-0.304800003767014
]
],
[
[
-2.43840003013611,
-0.304800003767014
],
[
-2.43840003013611,
7.31519985198975
]
],
[
[
-2.43840003013611,
7.31519985198975
],
[
-7.01039981842041,
7.31519985198975
]
],
[
[
-7.01039981842041,
7.31519985198975
],
[
-7.01039981842041,
-0.304800003767014
]
]
]
},
"scenarios": {
"cabinet_chain_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1 -> Cabinet 2"
},
"galaga_proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "galaga",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Galaga Proof of Concept"
},
"proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Proof of Concept"
},
"two_cabinet_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {
"game_activated": [
"glitch_code"
],
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Two Cabinet Test"
}
},
"version": 1
}
@@ -0,0 +1,38 @@
extends SceneTree
## Export designer resources to the controller's deployment catalog.
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var database: ProjectDatabase = load("res://data/project_database.tres")
var room: RoomDefinition = load("res://data/rooms/arcade_room.tres")
var catalog := {"version": 1, "games": {}, "cabinets": {}, "scenarios": {}, "layout": {"boundary": [], "walls": [], "devices": []}}
for game in database.games:
catalog.games[str(game.game_id)] = {"name": game.display_name, "requirements": Array(game.required_capabilities), "alternatives": Array(game.control_alternatives)}
for cabinet in database.cabinets:
var actions: Array = []
if cabinet.hardware_config != null:
for action in cabinet.hardware_config.actions: actions.append(str(action.action_id))
var placement := room.get_cabinet_placement(cabinet.cabinet_id)
catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null}
for scenario in database.scenarios:
var assignments: Array = []
for assignment in scenario.assignments:
if assignment.cabinet == null or assignment.game == null: continue
var mappings := {}
for mapping in assignment.event_mappings:
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions))
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "mappings": mappings})
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments}
for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y])
for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
for device in room.devices:
catalog.layout.devices.append({"id": str(device.device_id), "name": device.display_name, "position": [device.position.x, device.position.y]})
var file := FileAccess.open("res://tools/room_controller/catalog.json", FileAccess.WRITE)
if file == null:
push_error("Cannot write controller catalog")
quit(1)
return
file.store_string(JSON.stringify(catalog, "\t"))
print("Exported controller catalog from editor resources")
quit()
@@ -0,0 +1 @@
uid://bj7g63dhd8r1t
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}}
</style></head><body>
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header>
<main><div id="error" class="panel" role="alert"></div>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form>
<div id="workspace" hidden>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div>
</div></main><script>
'use strict';
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false;
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
</script></body></html>
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""Arcade room controller and browser operator; Python standard library only."""
import argparse
import hmac
import json
import os
from pathlib import Path
import sqlite3
import time
import uuid
from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, HTTPServer
class Room:
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
self.clock = clock
self.cabinet_id = cabinet_id
self.db = sqlite3.connect(database, check_same_thread=False)
self.db.execute("CREATE TABLE IF NOT EXISTS room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
self.db.execute("CREATE TABLE IF NOT EXISTS cabinet_rooms (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
row = self.db.execute("SELECT value FROM cabinet_rooms WHERE id=?", (cabinet_id,)).fetchone()
if row is None and cabinet_id == "cabinet_01":
row = self.db.execute("SELECT value FROM room WHERE id=1").fetchone()
self.state = json.loads(row[0]) if row else dict(
session="", phase="idle", reward="not requested", commands=[], seen=[], history=[])
# Never carry live connectivity across a controller restart.
self.last_seen = 0
self.boot = self.state.get("boot", "")
self.version = "unknown"
self.gpio = "unknown"
self.state.setdefault("game", "pacman")
self.state.setdefault("mappings", {"game_won": ["coin_release"]})
self.save()
def save(self):
with self.db:
self.db.execute("INSERT OR REPLACE INTO cabinet_rooms VALUES (?, ?)", (self.cabinet_id, json.dumps(self.state)))
def log(self, message):
self.state["history"].append(dict(time=self.clock(), message=message))
self.state["history"] = self.state["history"][-100:]
def online(self):
return self.last_seen > 0 and self.clock() - self.last_seen < 4
def snapshot(self):
self.expire()
return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
version=self.version, gpio=self.gpio,
last_seen=self.last_seen, protocol=2)
def expire(self):
for command in self.state["commands"]:
if command["status"] == "queued" and self.clock() > command["expires"]:
command["status"] = "unknown"
if command["type"] in ("reward", "output"):
self.state["reward" if command["type"] == "reward" else "last_output"] = "unknown — inspect hardware; not automatically retried"
else:
self.state["phase"] = "needs_prepare"
self.log(command["type"] + " acknowledgement timed out")
self.save()
def command(self, kind):
command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind,
status="queued", expires=self.clock() + 15)
if kind == "prepare":
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"))
self.state["commands"].append(command)
self.log("Queued " + kind)
return command
def operator(self, action):
self.expire()
if not self.online(): raise ValueError("Cabinet is offline; no command sent")
if any(c["status"] == "queued" and c["type"] not in ("output", "reward") for c in self.state["commands"]):
raise ValueError("Wait for the pending command or its timeout")
if action in ("prepare", "reset"):
if action == "prepare" and self.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
raise ValueError("Reset the active session before preparing again")
self.state.update(session=str(uuid.uuid4()), phase="preparing" if action == "prepare" else "resetting",
reward="not requested", commands=[], seen=[])
self.command(action)
elif action == "start":
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
self.state["phase"] = "starting"
self.command("start")
else:
raise ValueError("Unknown operator action")
self.save()
return self.snapshot()
def poll(self, data):
if data.get("cabinet") != self.cabinet_id or data.get("protocol") not in (1, 2):
raise ValueError("Wrong cabinet or protocol version")
boot = data.get("boot")
if not isinstance(boot, str) or not 1 <= len(boot) <= 100:
raise ValueError("Missing cabinet boot identity")
if self.boot and boot != self.boot:
self.state.update(session="", phase="needs_prepare", commands=[], seen=[], reward="not requested")
self.log("Cabinet restarted; operator must prepare a new session")
self.boot = boot
self.state["boot"] = boot
self.last_seen = self.clock()
self.version = str(data.get("version", "unknown"))[:80]
self.gpio = str(data.get("gpio", "unknown"))[:100]
self.expire()
reports = data.get("reports", [])
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
for report in reports:
if not isinstance(report, dict): raise ValueError("Invalid report")
if report.get("session") != self.state["session"]: continue
if report.get("kind") == "ack":
self.ack(report)
elif report.get("kind") == "event":
self.event(report)
self.save()
return dict(ok=True, session=self.state["session"],
commands=[c for c in self.state["commands"] if c["status"] == "queued"])
def ack(self, report):
command = next((c for c in self.state["commands"] if c["id"] == report.get("id")), None)
if command is None or command["status"] != "queued": return
success = report.get("ok") is True
command["status"] = "accepted" if success else "failed"
self.log("%s: %s" % (command["type"], str(report.get("detail", command["status"]))[:200]))
if command["type"] == "output":
self.state["last_output"] = "accepted (physical state unconfirmed)" if success else "failed or uncertain"
elif command["type"] == "reward":
statuses = [c["status"] for c in self.state["commands"] if c["type"] == "reward"]
if any(s in ("failed", "unknown") for s in statuses):
self.state["reward"] = "failed or uncertain — inspect cabinet"
elif all(s == "accepted" for s in statuses):
self.state["reward"] = "GPIO accepted (physical delivery unconfirmed)"
else:
self.state["reward"] = "requested"
elif success:
self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
if command["type"] == "prepare" and self.state.pop("auto_start", False):
self.state["phase"] = "starting"
self.command("start")
if command["type"] == "prepare":
for action in self.state["mappings"].get("game_activated", []):
self.command("output")["action_id"] = action
else:
self.state["phase"] = "needs_prepare"
def event(self, report):
event_id = report.get("id")
if not isinstance(event_id, str) or not 1 <= len(event_id) <= 100: return
if event_id in self.state["seen"]: return
self.state["seen"].append(event_id)
self.state["seen"] = self.state["seen"][-500:]
name = report.get("event")
if name == "started" and self.state["phase"] in ("ready", "starting"):
self.state["phase"] = "playing"
elif name == "won" and self.state["phase"] == "playing":
self.state["phase"] = "won"
if self.state["reward"] == "not requested" and self.state["mappings"].get("game_won"):
self.state["reward"] = "requested"
elif name == "lost" and self.state["phase"] == "playing":
self.state["phase"] = "lost"
else:
return
self.log(self.state["game"] + " " + name)
for action in self.state["mappings"].get("game_" + name, []):
command = self.command("reward" if name == "won" else "output")
command["action_id"] = action
class Fleet:
"""Editor catalog plus independently acknowledged cabinet sessions."""
def __init__(self, database, catalog, clock=time.time):
self.catalog = catalog
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
def compatible(self, cabinet, game):
definition = self.catalog["games"][game]
capabilities = set(self.catalog["cabinets"][cabinet]["capabilities"])
alternatives = definition.get("alternatives", [])
return set(definition["requirements"]) <= capabilities and (not alternatives or any(set(a) <= capabilities for a in alternatives))
def snapshot(self):
cabinets = {}
for key, room in self.rooms.items():
cabinets[key] = dict(room.snapshot(), definition=self.catalog["cabinets"][key],
compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)])
# Retain the single-cabinet desktop UI response while the browser offers the fleet view.
first = cabinets.get("cabinet_01", next(iter(cabinets.values())))
return dict(first, cabinets=cabinets, catalog=self.catalog)
def validate_assignment(self, item):
cabinet, game = item.get("cabinet"), item.get("game")
if cabinet not in self.rooms or game not in self.catalog["games"]:
raise ValueError("Unknown cabinet or game")
if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met")
if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime")
allowed = set(self.catalog["cabinets"][cabinet]["actions"])
for event, actions in item.get("mappings", {}).items():
if event not in ("game_activated", "game_started", "game_won", "game_lost"):
raise ValueError(f"Scenario event {event} is not implemented in managed runtime")
if not set(actions) <= allowed: raise ValueError("Scenario refers to an unknown hardware action")
room = self.rooms[cabinet]
room.expire()
if not room.online(): raise ValueError(f"{cabinet} is offline")
if room.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
raise ValueError(f"Reset {cabinet} before preparing a new assignment")
if any(c["status"] == "queued" for c in room.state["commands"]):
raise ValueError(f"{cabinet} has a pending command")
def operator(self, action, data=None):
data = data or {}
if action == "prepare_scenario":
scenario_id = data.get("scenario")
scenario = self.catalog["scenarios"].get(scenario_id)
if not scenario: raise ValueError("Unknown scenario")
assignments = scenario["assignments"]
if not assignments or len({a["cabinet"] for a in assignments}) != len(assignments):
raise ValueError("Scenario must have unique, nonempty cabinet assignments")
# Validate the whole plan before sending anything to any cabinet.
for item in assignments: self.validate_assignment(item)
by_id = {a["cabinet"]: a for a in assignments}
for item in assignments:
visited = {item["cabinet"]}
source = item.get("activate_after_win", "")
while source:
if source not in by_id: raise ValueError("Dependency cabinet must be assigned in the same scenario")
if source in visited: raise ValueError("Cabinet activation dependencies cannot form a cycle")
visited.add(source)
source = by_id[source].get("activate_after_win", "")
for item in assignments: self.prepare(item, scenario_id)
# Bind dependencies to this particular run, never an earlier win.
for item in assignments:
source = item.get("activate_after_win")
if source:
room = self.rooms[item["cabinet"]]
room.state["gate"] = dict(cabinet=source, session=self.rooms[source].state["session"])
room.log("Waiting for " + source + " to win")
room.save()
elif action == "prepare":
cabinet = data.get("cabinet", "cabinet_01")
item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={})
self.validate_assignment(item)
# Manual overrides do not inherit a reward from the previous scenario.
self.prepare(item, "manual override")
else:
cabinet = data.get("cabinet", "cabinet_01")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
if action == "start" and self.rooms[cabinet].state.get("gate"):
raise ValueError("This cabinet starts automatically after its dependency wins")
self.rooms[cabinet].operator(action)
if action == "reset":
self.rooms[cabinet].state.pop("gate", None)
self.rooms[cabinet].state.pop("auto_start", None)
self.rooms[cabinet].save()
self.advance()
return self.snapshot()
def prepare(self, item, scenario):
room = self.rooms[item["cabinet"]]
room.state.update(game=item["game"], mappings=item.get("mappings", {}),
variant=item.get("variant", "standard"), scenario=scenario)
room.state.pop("gate", None)
room.state.pop("auto_start", None)
room.operator("reset" if item.get("activate_after_win") else "prepare")
room.log("Assignment from " + scenario)
room.save()
def poll(self, data):
cabinet = data.get("cabinet")
if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
self.rooms[cabinet].poll(data)
self.advance()
room = self.rooms[cabinet]
return dict(ok=True, session=room.state["session"],
commands=[c for c in room.state["commands"] if c["status"] == "queued"])
def advance(self):
# Invalidate descendants when their source is reset/reassigned/restarted.
# Iterate to propagate invalidation through longer chains.
for _ in self.rooms:
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate: continue
source = self.rooms.get(gate["cabinet"])
if source is None or source.state["session"] != gate["session"]:
room.state.update(session=str(uuid.uuid4()), phase="needs_prepare", commands=[], seen=[], gate=None, auto_start=False)
room.log("Dependency session ended; prepare the scenario again")
room.save()
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate or room.state["phase"] != "waiting" or not room.online(): continue
source = self.rooms[gate["cabinet"]]
if source.state["phase"] == "won":
room.state.update(phase="preparing", auto_start=True)
room.command("prepare")
room.log("Activated by " + gate["cabinet"] + " win")
room.save()
def handler_for(room, operator_token, cabinet_token):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/", "/index.html"):
self.respond(404, {"error": "Not found"})
return
body = (Path(__file__).parent / "operator.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.wfile.write(body)
def do_POST(self):
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 <= length <= 65536: raise ValueError("Request too large")
data = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(data, dict): raise ValueError("Expected object")
expected = operator_token
if self.path == "/cabinet/poll":
expected = cabinet_token.get(data.get("cabinet"), "") if isinstance(cabinet_token, dict) else cabinet_token
if not expected:
self.respond(401, {"error": "Cabinet credentials not provisioned"})
return
if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + expected):
self.respond(401, {"error": "Unauthorized"})
return
origin = self.headers.get("Origin")
if origin and urlparse(origin).netloc != self.headers.get("Host"):
raise ValueError("Cross-origin requests are disabled")
if self.path == "/cabinet/poll": result = room.poll(data)
elif self.path == "/operator/state": result = room.snapshot()
elif self.path == "/operator/command":
result = room.operator(data.get("action"), data) if isinstance(room, Fleet) else room.operator(data.get("action"))
else: raise ValueError("Unknown endpoint")
self.respond(200, result)
except (ValueError, TypeError, KeyError) as exc:
self.respond(400, {"error": str(exc)})
def setup(self):
super().setup()
self.connection.settimeout(3)
def respond(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args): pass
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8766)
parser.add_argument("--database", default="arcade_room.sqlite3")
parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json")))
args = parser.parse_args()
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
try:
tokens = json.loads(os.environ.get("ARCADE_CABINET_TOKENS", "{}"))
except ValueError:
parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
if not isinstance(tokens, dict): parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
if cabinet: tokens.setdefault("cabinet_01", cabinet)
if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens):
parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets")
catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8"))
room = Fleet(Path(args.database), catalog)
server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens))
print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True)
try: server.serve_forever()
except KeyboardInterrupt: pass
finally:
server.server_close()
for cabinet_room in room.rooms.values(): cabinet_room.db.close()
if __name__ == "__main__": main()
@@ -0,0 +1,49 @@
"""Two real game clients; no GPIO, no live deployment database."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from http.server import HTTPServer
from test_room import ROOT, server
with tempfile.TemporaryDirectory() as temp:
fleet=server.Fleet(Path(temp)/'chain.db',json.loads((ROOT/'tools/room_controller/catalog.json').read_text()))
service=HTTPServer(('127.0.0.1',0),server.handler_for(fleet,'test-operator-token',{'cabinet_01':'test-token-one-123','cabinet_02':'test-token-two-123'}))
thread=threading.Thread(target=service.serve_forever,daemon=True)
thread.start()
url='http://127.0.0.1:'+str(service.server_port)
processes=[]
def post(path,data={}):
request=urllib.request.Request(url+path,data=json.dumps(data).encode(),headers={'Authorization':'Bearer test-operator-token'})
with urllib.request.urlopen(request,timeout=3) as response: return json.load(response)
def wait(predicate):
deadline=time.monotonic()+12
while time.monotonic()<deadline:
state=post('/operator/state')
if predicate(state['cabinets']): return state
time.sleep(.1)
raise AssertionError('Timed out waiting for chain state')
try:
for cabinet,token in [('cabinet_01','test-token-one-123'),('cabinet_02','test-token-two-123')]:
processes.append(subprocess.Popen([sys.argv[1],'--headless','--path',str(ROOT),'--log-file',str(Path(temp)/(cabinet+'.log')),'--script','tests/room_network/managed_agent.gd','--','--cabinet='+cabinet,'--test-no-reward'],env=dict(os.environ,ARCADE_ROOM_URL=url,ARCADE_CABINET_TOKEN=token)))
wait(lambda c:c['cabinet_01']['online'] and c['cabinet_02']['online'])
post('/operator/command',{'action':'prepare_scenario','scenario':'cabinet_chain_test'})
wait(lambda c:c['cabinet_01']['phase']=='ready' and c['cabinet_02']['phase']=='waiting')
post('/operator/command',{'action':'start','cabinet':'cabinet_01'})
state=wait(lambda c:c['cabinet_01']['phase']=='won' and c['cabinet_02']['phase']=='won')
commands=state['cabinets']['cabinet_02']['commands']
assert [c['type'] for c in commands]==['reset','prepare','start']
assert all(c['status']=='accepted' for c in commands)
for process in processes: assert process.wait(timeout=8)==0
print('PASS: Pac-Man win automatically loads and starts Galaga on the second client exactly once')
finally:
for process in processes:
if process.poll() is None:
process.terminate(); process.wait(timeout=5)
service.shutdown(); service.server_close(); thread.join()
for room in fleet.rooms.values(): room.db.close()
+98
View File
@@ -0,0 +1,98 @@
import unittest
import test_fleet
import tempfile
from pathlib import Path
from test_room import server
class ChainTests(unittest.TestCase):
setUp = test_fleet.FleetTests.setUp
tearDown = test_fleet.FleetTests.tearDown
poll = test_fleet.FleetTests.poll
ack = test_fleet.FleetTests.ack
def prepare(self):
self.fleet.operator('prepare_scenario', {'scenario':'cabinet_chain_test'})
self.ack('cabinet_01')
self.ack('cabinet_02')
def win(self):
source = self.fleet.rooms['cabinet_01']
self.poll('cabinet_01', [dict(kind='event', event=e, id=e, session=source.state['session']) for e in ['started','won']])
def test_wait_load_start_once(self):
self.prepare()
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'], 'waiting')
self.assertFalse(self.poll('cabinet_02')['commands'])
with self.assertRaises(ValueError): self.fleet.operator('start', {'cabinet':'cabinet_02'})
self.win()
commands = self.poll('cabinet_02')['commands']
self.assertEqual([c['type'] for c in commands], ['prepare'])
self.assertEqual(commands[0]['game'], 'galaga')
self.win()
self.assertEqual(len(self.poll('cabinet_02')['commands']), 1)
self.ack('cabinet_02')
self.assertEqual([c['type'] for c in self.poll('cabinet_02')['commands']], ['start'])
self.ack('cabinet_02')
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'], 'playing')
self.win()
self.assertFalse(self.poll('cabinet_02')['commands'])
def test_loss_does_not_activate(self):
self.prepare()
source=self.fleet.rooms['cabinet_01']
self.poll('cabinet_01',[dict(kind='event',event=e,id=e,session=source.state['session']) for e in ['started','lost']])
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'],'waiting')
def test_reset_invalidates_chain_and_old_win(self):
self.prepare()
old=self.fleet.rooms['cabinet_01'].state['session']
self.fleet.operator('reset', {'cabinet':'cabinet_01'})
self.poll('cabinet_01',[dict(kind='event',event='won',id='late',session=old)])
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'],'needs_prepare')
self.assertFalse(self.poll('cabinet_02')['commands'])
def test_target_reconnect_receives_activation(self):
self.prepare()
self.now+=5
self.win()
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'],'waiting')
self.assertEqual(self.poll('cabinet_02')['commands'][0]['type'],'prepare')
def test_failed_load_does_not_start(self):
self.prepare()
self.win()
command=self.poll('cabinet_02')['commands'][0]
self.poll('cabinet_02',[dict(kind='ack',id=command['id'],session=command['session'],ok=False)])
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'],'needs_prepare')
self.assertFalse(self.poll('cabinet_02')['commands'])
def test_cycles_rejected_before_dispatch(self):
self.catalog['scenarios']['cabinet_chain_test']['assignments'][0]['activate_after_win']='cabinet_02'
with self.assertRaisesRegex(ValueError,'cycle'):
self.fleet.operator('prepare_scenario', {'scenario':'cabinet_chain_test'})
self.assertFalse(self.poll('cabinet_01')['commands'])
self.assertFalse(self.poll('cabinet_02')['commands'])
def test_restart_preserves_gate_and_deduplicates_auto_start(self):
self.prepare()
self.win()
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'chain.db'
fleet=server.Fleet(path,self.catalog,lambda:self.now)
for key,room in fleet.rooms.items():
room.state=self.fleet.rooms[key].state.copy()
room.save()
room.db.close()
fleet=server.Fleet(path,self.catalog,lambda:self.now)
try:
target=fleet.rooms['cabinet_02']
command=next(c for c in target.state['commands'] if c['type']=='prepare')
report=dict(kind='ack',id=command['id'],session=command['session'],ok=True)
payload=dict(cabinet='cabinet_02',protocol=2,boot='cabinet_02',reports=[report,report])
fleet.poll(payload)
self.assertEqual(sum(c['type']=='start' for c in target.state['commands']),1)
fleet.poll(payload)
self.assertEqual(sum(c['type']=='start' for c in target.state['commands']),1)
finally:
for room in fleet.rooms.values(): room.db.close()
+2
View File
@@ -1,5 +1,7 @@
# Start the Arcade stack # 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). 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 | | Mode | Starts |
+27 -1
View File
@@ -1,5 +1,7 @@
# Arcade room controller and browser operator # Arcade room controller and browser operator
See the [cabinet setup guide](../../docs/cabinet_setup.md) for the supplied internal token registry and PC-versus-Pi configuration examples.
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. For supervised startup on Windows or the Pi, use the [stack launch scripts](../launch/README.md). The manual commands below remain available for troubleshooting.
@@ -51,7 +53,7 @@ The sample cabinet profiles are intentionally not expanded to claim hardware the
## 2. Start the controller on the Mac mini (or PC) ## 2. Start the controller on the Mac mini (or PC)
Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. Keep tokens outside source control. Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. The assigned tokens for this internal installation are recorded in the cabinet setup guide.
macOS / Linux: macOS / Linux:
@@ -139,3 +141,27 @@ godot --headless --path . --script tests/room_network/check_capabilities.gd
The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports. The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports.
Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing. Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing.
## Two-Pi chain test: Cabinet 1 wins -> Cabinet 2 starts
Select **Cabinet 1 -> Cabinet 2** (`cabinet_chain_test`) in the browser. This separate sample uses Pac-Man on `cabinet_01` followed by Galaga on `cabinet_02`, with no GPIO event mappings required.
1. On Windows, run the controller only: `.\Start-Arcade.ps1 -Mode controller -Browser`. Do not also start a Windows game using either Pi's cabinet identity.
2. Provision distinct cabinet tokens for `cabinet_01` and `cabinet_02` in the controller configuration.
3. On Pi 1 set `[cabinet] id = cabinet_01`; on Pi 2 set `id = cabinet_02`. Use each cabinet's matching token and the Windows controller URL, then run `bash start-arcade.sh cabinet` on both.
4. Confirm both cabinets are ONLINE. Reset any active assignments, select **Cabinet 1 -> Cabinet 2**, and click **Prepare assigned cabinets**.
5. Cabinet 1 becomes READY with Pac-Man. Cabinet 2 unloads any game, then shows WAITING in the operator; the Pi shows its existing waiting-for-operator screen. Its physical Start button cannot bypass the dependency because no game is loaded.
6. Start Cabinet 1 and win its configured Pac-Man objective. Cabinet 2 automatically loads Galaga, waits for the game's readiness acknowledgement, and starts it. No operator click or physical Start on Cabinet 2 is needed.
To repeat, reset Cabinet 1 and Cabinet 2, then prepare the scenario again. Resetting/reassigning/restarting a source cabinet invalidates its dependent sessions, including any running dependent game; prepare the scenario again. A loss does not activate the next cabinet. Duplicate win reports and acknowledgements do not create additional starts. An offline target waits until the same process reconnects; a restarted target or a failed/expired load requires operator preparation again.
The activation field is `activate_after_win` on a `ScenarioAssignment` resource (editable in the Inspector). Set it to another cabinet ID in the same scenario, or leave it empty for normal immediate preparation. Export the catalog after editing it. Missing dependencies and cycles are rejected before dispatch. This activation behavior belongs to the managed network controller; the local editor simulation does not execute this network chain.
For this update, deploy **server.py, catalog.json, and operator.html** together and restart the Windows controller, then refresh the browser. Existing protocol-2 Pi game exports already support the reset/prepare/start commands used here; a Pi game re-export is not required solely for this controller feature.
Automated two-client check (no GPIO; wins forced only by the test harness):
```sh
python3 tests/room_network/check_chain_integration.py /path/to/godot
```
+34 -2
View File
@@ -9,9 +9,18 @@
"joystick_4way", "joystick_4way",
"button_1", "button_1",
"start_button", "start_button",
"joystick_horizontal" "joystick_horizontal",
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
], ],
"name": "Cabinet 01", "name": "Master Cabinet",
"position": [ "position": [
-5.94849967956543, -5.94849967956543,
0.147500038146973 0.147500038146973
@@ -237,9 +246,29 @@
] ]
}, },
"scenarios": { "scenarios": {
"cabinet_chain_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1 -> Cabinet 2"
},
"galaga_proof_of_concept": { "galaga_proof_of_concept": {
"assignments": [ "assignments": [
{ {
"activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "galaga", "game": "galaga",
"mappings": { "mappings": {
@@ -255,6 +284,7 @@
"proof_of_concept": { "proof_of_concept": {
"assignments": [ "assignments": [
{ {
"activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "pacman", "game": "pacman",
"mappings": { "mappings": {
@@ -270,6 +300,7 @@
"two_cabinet_test": { "two_cabinet_test": {
"assignments": [ "assignments": [
{ {
"activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "pacman", "game": "pacman",
"mappings": { "mappings": {
@@ -283,6 +314,7 @@
"variant": "standard" "variant": "standard"
}, },
{ {
"activate_after_win": "",
"cabinet": "cabinet_02", "cabinet": "cabinet_02",
"game": "galaga", "game": "galaga",
"mappings": { "mappings": {
+1 -1
View File
@@ -22,7 +22,7 @@ func run() -> void:
for mapping in assignment.event_mappings: for mapping in assignment.event_mappings:
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = [] if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions)) mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions))
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "mappings": mappings}) assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "mappings": mappings})
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments} catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments}
for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y]) for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y])
for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]]) for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
+2 -2
View File
@@ -18,10 +18,10 @@ const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e} function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value} async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before} function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value} function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'} function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}} function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared');$('steps').replaceChildren(...['idle','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()} function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}} async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}} async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000); $('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
+58 -3
View File
@@ -135,7 +135,10 @@ class Room:
else: else:
self.state["reward"] = "requested" self.state["reward"] = "requested"
elif success: elif success:
self.state["phase"] = {"prepare": "ready", "reset": "idle", "start": "playing"}[command["type"]] self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
if command["type"] == "prepare" and self.state.pop("auto_start", False):
self.state["phase"] = "starting"
self.command("start")
if command["type"] == "prepare": if command["type"] == "prepare":
for action in self.state["mappings"].get("game_activated", []): for action in self.state["mappings"].get("game_activated", []):
self.command("output")["action_id"] = action self.command("output")["action_id"] = action
@@ -216,7 +219,24 @@ class Fleet:
raise ValueError("Scenario must have unique, nonempty cabinet assignments") raise ValueError("Scenario must have unique, nonempty cabinet assignments")
# Validate the whole plan before sending anything to any cabinet. # Validate the whole plan before sending anything to any cabinet.
for item in assignments: self.validate_assignment(item) for item in assignments: self.validate_assignment(item)
by_id = {a["cabinet"]: a for a in assignments}
for item in assignments:
visited = {item["cabinet"]}
source = item.get("activate_after_win", "")
while source:
if source not in by_id: raise ValueError("Dependency cabinet must be assigned in the same scenario")
if source in visited: raise ValueError("Cabinet activation dependencies cannot form a cycle")
visited.add(source)
source = by_id[source].get("activate_after_win", "")
for item in assignments: self.prepare(item, scenario_id) for item in assignments: self.prepare(item, scenario_id)
# Bind dependencies to this particular run, never an earlier win.
for item in assignments:
source = item.get("activate_after_win")
if source:
room = self.rooms[item["cabinet"]]
room.state["gate"] = dict(cabinet=source, session=self.rooms[source].state["session"])
room.log("Waiting for " + source + " to win")
room.save()
elif action == "prepare": elif action == "prepare":
cabinet = data.get("cabinet", "cabinet_01") cabinet = data.get("cabinet", "cabinet_01")
item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={}) item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={})
@@ -226,14 +246,23 @@ class Fleet:
else: else:
cabinet = data.get("cabinet", "cabinet_01") cabinet = data.get("cabinet", "cabinet_01")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet") if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
if action == "start" and self.rooms[cabinet].state.get("gate"):
raise ValueError("This cabinet starts automatically after its dependency wins")
self.rooms[cabinet].operator(action) self.rooms[cabinet].operator(action)
if action == "reset":
self.rooms[cabinet].state.pop("gate", None)
self.rooms[cabinet].state.pop("auto_start", None)
self.rooms[cabinet].save()
self.advance()
return self.snapshot() return self.snapshot()
def prepare(self, item, scenario): def prepare(self, item, scenario):
room = self.rooms[item["cabinet"]] room = self.rooms[item["cabinet"]]
room.state.update(game=item["game"], mappings=item.get("mappings", {}), room.state.update(game=item["game"], mappings=item.get("mappings", {}),
variant=item.get("variant", "standard"), scenario=scenario) variant=item.get("variant", "standard"), scenario=scenario)
room.operator("prepare") room.state.pop("gate", None)
room.state.pop("auto_start", None)
room.operator("reset" if item.get("activate_after_win") else "prepare")
room.log("Assignment from " + scenario) room.log("Assignment from " + scenario)
room.save() room.save()
@@ -241,7 +270,33 @@ class Fleet:
cabinet = data.get("cabinet") cabinet = data.get("cabinet")
if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2") if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet") if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
return self.rooms[cabinet].poll(data) self.rooms[cabinet].poll(data)
self.advance()
room = self.rooms[cabinet]
return dict(ok=True, session=room.state["session"],
commands=[c for c in room.state["commands"] if c["status"] == "queued"])
def advance(self):
# Invalidate descendants when their source is reset/reassigned/restarted.
# Iterate to propagate invalidation through longer chains.
for _ in self.rooms:
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate: continue
source = self.rooms.get(gate["cabinet"])
if source is None or source.state["session"] != gate["session"]:
room.state.update(session=str(uuid.uuid4()), phase="needs_prepare", commands=[], seen=[], gate=None, auto_start=False)
room.log("Dependency session ended; prepare the scenario again")
room.save()
for room in self.rooms.values():
gate = room.state.get("gate")
if not gate or room.state["phase"] != "waiting" or not room.online(): continue
source = self.rooms[gate["cabinet"]]
if source.state["phase"] == "won":
room.state.update(phase="preparing", auto_start=True)
room.command("prepare")
room.log("Activated by " + gate["cabinet"] + " win")
room.save()
def handler_for(room, operator_token, cabinet_token): def handler_for(room, operator_token, cabinet_token):