Expand Glitch operations hub, cabinet authoring, deployment, and RFID support
Operator hub - Introduce Escape Gallery branding and Glitch: the Dark Machine room views - Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states - Add device feedback panels with RFID readings, input/output states, and stale status - Add logged operator hardware interventions with request deduplication Room and cabinet authoring - Add a visual custom-PCB connection editor with per-cabinet device inventory - Support device IDs, bindings, wiring notes, input polarity, and validation - Add physical-room label/dimension toggles and editable doorway annotations - Preserve doorway changes through undo, save, and revert - Expand cabinet profiles, room layout, and Glitch scenario resources Scenarios and gameplay - Add per-assignment game configuration and runtime feature validation - Add timed test wins and Frogger individual-home objectives - Add the three-cabinet timed/score/Frogger test scenario - Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display - Require physical Start after an accepted scan and prevent duplicate credits Pi hardware and deployment - Add GPIO feedback sampling and a separate multiplexed RFID helper - Validate reader pin ownership and input/output conflicts - Add SSH cabinet startup and Pi desktop-session service support - Add master packaging, site setup, SSH enrollment, and staged cabinet releases - Verify deployment manifests and support explicit activation/status/log commands Documentation and verification - Consolidate the root README around the current architecture and setup workflow - Add the complete master/Pi/RFID export and deployment walkthrough - Document network isolation, credentials, card identification, and rollback - Add PCB, room-editor, feedback, credit, deployment, and controller tests - Isolate protocol tests from designer-edited cabinet configurations Known limitations - RFID helper compilation and physical Pi commissioning remain unverified - Fleet provisioning does not yet configure RFID-only installations completely - Broader Pac-Man tests retain a ghost-capture assertion failure - Staged exports must be rebuilt and checked before deploying runtime 2.3
This commit is contained in:
1 parent
705ec31e0b
commit
c1b2195834
90 files changed
+4969
-456
No files matched your search
@@ -165,3 +165,40 @@ 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
|
||||
```
|
||||
|
||||
|
||||
## Three-cabinet objective test
|
||||
|
||||
See the [three-cabinet walkthrough](../../docs/three_cabinet_test.md) for the separate Pac-Man five-second auto-win → Galaga 500-point objective → Frogger one-home scenario. This scenario requires updated `arcade-cabinet-2.1` game exports on all three cabinets.
|
||||
|
||||
## Escape Gallery browser hub
|
||||
|
||||
The browser now opens an **Escape Gallery** overview, with **Glitch: the Dark Machine** as the current room. Navigation provides Room control, Cabinets, Hardware, and Activity. Update the controller Python files and `operator.html`, restart the controller, and reload the browser. These controls use the existing cabinet protocol; this UI update alone does not require a new Godot export.
|
||||
|
||||
Room control uses the boundary, walls, device references and cabinet positions exported in `catalog.json`. Cabinets without a position still appear in the cabinet list. Selecting a cabinet opens an original generic cabinet illustration: its marquee follows the assigned game and its screen shows reported phase. It is not gameplay video. Custom cabinet artwork and game-specific screen/marquee assets can replace this illustration later.
|
||||
|
||||
Cabinet state refreshes approximately every 1.5 seconds. Device markers are reference locations until sensor integration is implemented. Output rings show pending or failed/unconfirmed commands, not physical device activation. Per-cabinet RFID, digital input, and electrical output feedback is available through the optional [PCB feedback bridge](../pi_gpio/README.md#pcb-live-feedback). Cabinet and Hardware views show readings, errors, and stale status; Activity records changes. The Pac-Man cabinet uses assigned-game artwork, not a live video feed.
|
||||
|
||||
Hardware offers only each cabinet's configured actions. An operator reviews the action and supplies a reason. The controller requires real GPIO mode, an online prepared cabinet, the current session, and no pending commands. Requests are deduplicated and reasons appear in Activity. An accepted acknowledgement still does not establish physical delivery. A timeout is not retried automatically. Tokens remain in browser memory and Lock clears access on that device.
|
||||
|
||||
Optional hub branding and links to other rooms/services can be set in a separate JSON file:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Escape Gallery",
|
||||
"room_name": "Glitch: the Dark Machine",
|
||||
"services": [
|
||||
{
|
||||
"id": "trivia",
|
||||
"name": "Trivia",
|
||||
"kind": "trivia",
|
||||
"url": "http://TRIVIA_HOST:8080",
|
||||
"description": "Open the trivia operator application"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace the example host before using it, or keep `services` empty. Entries are links, not aggregated live status or shared authentication. Allowed kinds are `room`, `trivia`, and `service`. URLs cannot contain credentials or token query strings.
|
||||
|
||||
Pass `--hub-config /path/to/hub.json` to `server.py`, or add `hub_config = /path/to/hub.json` under `[controller]` in the launcher INI. Restart after changes. Keep this optional setting when regenerating deployment configurations. Current authentication remains the shared operator token; individual staff identities are not implemented.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bqb2ndoni3hqb"
|
||||
path="res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tools/room_controller/art/pacman_marquee.png"
|
||||
dest_files=["res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b5u7udnl0fhnw"
|
||||
path="res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tools/room_controller/art/pacman_screen.png"
|
||||
dest_files=["res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -2,59 +2,162 @@
|
||||
"cabinets": {
|
||||
"cabinet_01": {
|
||||
"actions": [
|
||||
"coin_release",
|
||||
"start_light"
|
||||
"coin_slot_1",
|
||||
"lock_released"
|
||||
],
|
||||
"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"
|
||||
"select_button"
|
||||
],
|
||||
"name": "Master Cabinet",
|
||||
"devices": [
|
||||
{
|
||||
"id": "cab1_btn_red",
|
||||
"kind": "input",
|
||||
"name": "Red Button",
|
||||
"port": "J17",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_grn",
|
||||
"kind": "input",
|
||||
"name": "Green Button",
|
||||
"port": "J18",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_blue",
|
||||
"kind": "input",
|
||||
"name": "Blue Button",
|
||||
"port": "J19",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_ylw",
|
||||
"kind": "input",
|
||||
"name": "Yellow Btn",
|
||||
"port": "J20",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid2",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 2",
|
||||
"port": "J2",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid1",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 1",
|
||||
"port": "J1",
|
||||
"pull_up": true
|
||||
}
|
||||
],
|
||||
"name": "Cabinet 01",
|
||||
"position": [
|
||||
-5.94849967956543,
|
||||
0.147500038146973
|
||||
-6.55779981613159,
|
||||
1.98219990730286
|
||||
]
|
||||
},
|
||||
"cabinet_02": {
|
||||
"actions": [
|
||||
"coin_release",
|
||||
"start_light",
|
||||
"glitch_code"
|
||||
"lock_released"
|
||||
],
|
||||
"capabilities": [
|
||||
"joystick_4way",
|
||||
"button_1",
|
||||
"joystick_horizontal",
|
||||
"start_button",
|
||||
"joystick_horizontal"
|
||||
"select_button",
|
||||
"button_1"
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "cab1_btn_red",
|
||||
"kind": "input",
|
||||
"name": "Red Button",
|
||||
"port": "J17",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_grn",
|
||||
"kind": "input",
|
||||
"name": "Green Button",
|
||||
"port": "J18",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_blue",
|
||||
"kind": "input",
|
||||
"name": "Blue Button",
|
||||
"port": "J19",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_btn_ylw",
|
||||
"kind": "input",
|
||||
"name": "Yellow Btn",
|
||||
"port": "J20",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid2",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 2",
|
||||
"port": "J2",
|
||||
"pull_up": true
|
||||
},
|
||||
{
|
||||
"id": "cab1_rfid1",
|
||||
"kind": "rfid",
|
||||
"name": "RFID Reader 1",
|
||||
"port": "J1",
|
||||
"pull_up": true
|
||||
}
|
||||
],
|
||||
"name": "Cabinet 02",
|
||||
"position": [
|
||||
-3.51539182662964,
|
||||
0.142745211720467
|
||||
-2.89779996871948,
|
||||
1.98219990730286
|
||||
]
|
||||
},
|
||||
"cabinet_03": {
|
||||
"actions": [
|
||||
"coin_release"
|
||||
"coin_slot_1",
|
||||
"lock_released"
|
||||
],
|
||||
"capabilities": [
|
||||
"spinner",
|
||||
"button_1",
|
||||
"start_button",
|
||||
"select_button"
|
||||
],
|
||||
"devices": [],
|
||||
"name": "Cabinet 03",
|
||||
"position": [
|
||||
-2.89779996871948,
|
||||
3.20219993591309
|
||||
]
|
||||
},
|
||||
"cabinet_04": {
|
||||
"actions": [
|
||||
"coin_slot_1",
|
||||
"lock_released"
|
||||
],
|
||||
"capabilities": [
|
||||
"roller",
|
||||
"button_1",
|
||||
"button_2",
|
||||
"button_3",
|
||||
"select_button",
|
||||
"start_button"
|
||||
],
|
||||
"name": "Cabinet 03",
|
||||
"position": null
|
||||
"devices": [],
|
||||
"name": "Cabinet 4",
|
||||
"position": [
|
||||
-6.55779981613159,
|
||||
3.20219993591309
|
||||
]
|
||||
}
|
||||
},
|
||||
"games": {
|
||||
@@ -252,6 +355,7 @@
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
@@ -259,43 +363,106 @@
|
||||
"activate_after_win": "cabinet_01",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"game_config": {},
|
||||
"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": {
|
||||
"eg_glitch": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {
|
||||
"game_won": [
|
||||
"coin_release"
|
||||
]
|
||||
},
|
||||
"game_config": {},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"game_config": {},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_03",
|
||||
"game": "tempest",
|
||||
"game_config": {},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_04",
|
||||
"game": "missile_command",
|
||||
"game_config": {},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Proof of Concept"
|
||||
"name": "Glitch: The Dark Machine"
|
||||
},
|
||||
"pacman_rfid_credit_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {
|
||||
"boards_to_win": 1,
|
||||
"rfid_allowed_uids": [],
|
||||
"rfid_credit_device": "cab1_rfid1",
|
||||
"score_to_win": 0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "Cabinet 1: Pac-Man RFID credit"
|
||||
},
|
||||
"three_cabinet_objective_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {
|
||||
"boards_to_win": 0,
|
||||
"score_to_win": 0,
|
||||
"test_auto_win_seconds": 5.0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "cabinet_01",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"game_config": {
|
||||
"score_to_win": 500,
|
||||
"stages_to_win": 0
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "cabinet_02",
|
||||
"cabinet": "cabinet_03",
|
||||
"game": "frogger",
|
||||
"game_config": {
|
||||
"boards_to_win": 0,
|
||||
"homes_to_win": 1
|
||||
},
|
||||
"mappings": {},
|
||||
"variant": "standard"
|
||||
}
|
||||
],
|
||||
"name": "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
|
||||
},
|
||||
"two_cabinet_test": {
|
||||
"assignments": [
|
||||
@@ -303,6 +470,7 @@
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"game_config": {},
|
||||
"mappings": {
|
||||
"game_started": [
|
||||
"start_light"
|
||||
@@ -317,6 +485,7 @@
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"game_config": {},
|
||||
"mappings": {
|
||||
"game_activated": [
|
||||
"glitch_code"
|
||||
|
||||
@@ -14,6 +14,7 @@ func run() -> void:
|
||||
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}
|
||||
catalog.cabinets[str(cabinet.cabinet_id)]["devices"] = cabinet.pcb_config.feedback_devices() if cabinet.pcb_config else []
|
||||
for scenario in database.scenarios:
|
||||
var assignments: Array = []
|
||||
for assignment in scenario.assignments:
|
||||
@@ -22,7 +23,7 @@ func run() -> void:
|
||||
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})
|
||||
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), "game_config": assignment.game_config, "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]])
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
<!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>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Escape Gallery · Operations</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);
|
||||
:root{font:16px/1.5 system-ui,sans-serif;color:#25342e;background:#f3f4ef;--muted:#627168;--line:#d7dfd5;--accent:#21644e}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 16px;min-height:46px;border:1px solid #bdccc0;border-radius:8px;background:white;color:inherit;font-weight:600;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:#edf3eb}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button.danger{color:#a44031}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #d29932;outline-offset:3px}input,select,textarea{padding:11px;border:1px solid #b8c7ba;border-radius:8px;min-height:46px;background:white;color:inherit;max-width:100%}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(26px,3vw,36px);line-height:1.2;letter-spacing:-1px;margin-bottom:12px}h2{font-size:20px;margin-bottom:14px}h3{font-size:16px;margin-bottom:8px}p{margin-bottom:14px}small,.muted{color:var(--muted)}small{font-size:13px}label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}[hidden]{display:none!important}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.7px;color:var(--muted);text-transform:uppercase;margin-bottom:10px}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.between{justify-content:space-between}.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:24px}.columns{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,235px),1fr));gap:16px}.badge{display:inline-flex;gap:6px;align-items:center;border-radius:25px;padding:4px 9px;font-size:12px;font-weight:700;background:#edf0e9;color:#53634f}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.good{background:#e0f0e3;color:#28623a}.warn{background:#fff0cd;color:#78550e}.bad{background:#fbe8e2;color:#9c3d29}.blue{background:#e4edf9;color:#2c6095}.notice{background:#fff6e1;border:1px solid #dec18b;color:#775115;padding:14px 18px;border-radius:9px;margin-bottom:18px;white-space:pre-line}.notice:empty{display:none}#error{background:#ffede7;border-color:#dcb3a5;color:#8b3727}#toast{background:#e5f1e4;border-color:#b5d1b6;color:#2a5737}#login{max-width:470px;margin:10vh auto}#login input{width:100%;margin-bottom:16px}#login .primary{width:100%}.mark{display:grid;place-items:center;width:42px;height:42px;border-radius:10px;background:#dfb75b;color:#233c32;font-weight:850}#shell{display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;background:#1c322c;color:#e1e9df;padding:28px 18px}.brand{display:flex;gap:10px;align-items:center;margin-bottom:35px}.brand strong{font-size:17px}.brand small{display:block;font-size:10px;letter-spacing:2px;color:#afc4b5}.nav-label{font-size:10px;letter-spacing:1.6px;color:#a6bfac;margin:22px 12px 8px}.nav{width:100%;justify-content:flex-start;background:transparent;border:0;color:#c4d4c4;padding:12px;margin-bottom:4px;text-align:left;font-size:14px}.nav:hover{background:#2a4537}.nav[aria-current=page]{background:#3c5944;color:white}.nav span{color:#d8bb75;width:20px;font-size:18px}.sidebar footer{margin-top:auto;border-top:1px solid #405742;padding-top:20px}.sidebar footer p{font-size:12px;color:#b8c9b5}.sidebar footer button{background:transparent;color:#e2eadb;border-color:#688267;width:100%}.content{min-width:0}.topbar{padding:16px 30px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px;background:#fafbf6}.topbar strong,.topbar small{display:block}.topbar strong{font-size:14px}.topbar button{font-size:13px;padding:8px 12px;min-height:40px}main{max-width:1600px;margin:auto;padding:30px}.heading{margin-bottom:25px}.heading p{color:var(--muted);max-width:750px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}.stat{padding:19px;background:white;border:1px solid var(--line);border-radius:10px}.stat strong{display:block;font-size:30px;line-height:1.2}.stat span{font-size:12px;color:var(--muted)}.experience{border-top:4px solid #c9a75d}.experience h2{font-size:25px;margin-top:16px}.experience p{color:var(--muted)}.experience .button,.experience button{margin-top:12px}.link{border:0;background:none;padding:0;min-height:32px;color:var(--accent);font-size:14px}.attention{display:flex;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid var(--line)}.attention:last-child{border:0}.attention div{flex:1}.attention strong,.attention small{display:block}.card{display:block;padding:18px;text-align:left;border:1px solid var(--line);border-radius:10px;background:white}.card[aria-pressed=true]{border:2px solid var(Line truncated
|
||||
.cabinet-visual{margin:auto;width:min(100%,270px);padding:14px 16px 22px;background:#252f2c;border:5px solid #17201c;border-radius:10px 10px 5px 5px;color:#eff5e8;box-shadow:7px 10px 0 #00000010}.marquee{border:3px solid #101813;background:#ecce7e;color:#233420;font-weight:850;text-align:center;padding:9px 6px;letter-spacing:1px;text-transform:uppercase;font-size:15px}.screen{margin:15px 0 0;background:#101d17;border:9px solid #414b42;border-radius:5px;aspect-ratio:1.1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:8px;padding:12px}.screen .screen-game{font-size:20px;font-weight:750;color:#e7c965}.screen .screen-state{font-size:12px;letter-spacing:1px;text-transform:uppercase;color:#b8ddad}.screen .screen-detail{font-size:11px;color:#9aafa0}.deck{background:#3b473d;padding:14px 10px;display:flex;justify-content:space-around;align-items:center;transform:perspective(100px) rotateX(12deg);border-bottom:4px solid #18271d}.joystick{width:18px;height:18px;background:#d8ba60;border-radius:50%;box-shadow:0 6px 0 #142318}.buttons{display:flex;gap:6px}.buttons i{height:9px;width:9px;background:#cce0ba;border-radius:50%}.coin-door{width:58px;height:55px;border:3px solid #414d40;border-radius:3px;margin:25px auto 8px;display:grid;place-items:center}.coin-slot{width:20px;height:5px;background:#101a12}.cabinet-caption{text-align:center;font-size:12px;color:var(--muted);margin-top:14px}.visual-panel{background:#f4f6ee;padding:22px;border-radius:9px}
|
||||
@media(max-width:1050px){#shell{grid-template-columns:188px 1fr}.sidebar{padding:24px 12px}.brand strong{font-size:15px}.columns{grid-template-columns:1fr}main{padding:24px}.topbar{padding:16px 24px}}@media(max-width:680px){#shell{display:block}.sidebar{height:auto;position:static;display:block;padding:16px}.brand{margin-bottom:14px}.sidebar nav{display:flex;overflow-x:auto;gap:5px}.nav-label,.sidebar footer{display:none}.nav{width:auto;white-space:nowrap;margin:0;padding:10px}.nav span{display:none}.topbar{padding:12px 16px}main{padding:22px 16px}.panel{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.activity li{grid-template-columns:70px 1fr;gap:4px 10px}.activity .message{grid-column:2}#map{height:270px}}@media(min-width:681px){#mobileLock{display:none}}
|
||||
.marquee img{width:100%;height:100%;object-fit:contain;background:white}.marquee:has(img){padding:0;height:82px}.screen{aspect-ratio:224/288;position:relative}.screen-game:has(img){width:100%;height:100%}.screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}.screen:has(img){padding:0}.screen:has(img) .screen-state{position:absolute;bottom:4px;background:#000b;padding:3px 8px}.screen:has(img) .screen-detail{display:none}.cabinet-visual{border-left:12px solid #24392b;border-right:12px solid #24392b;box-shadow:8px 10px 0 #172a251a}.feedback-list{display:grid;gap:10px;margin-top:16px}.feedback-item{border-left:4px solid #89918d;padding:12px;background:#f4f6ee}.feedback-item.active{border-color:#287c58}.feedback-item.stale{opacity:.65}.cabinet-visual[data-game="pacman"]{border-color:#e8bf21;background:#e8bf21}.cabinet-visual[data-game="pacman"] .deck{background:#303334}.cabinet-visual[data-game="pacman"] .coin-door{background:#26292a;border-color:#171c1a}</style></head><body>
|
||||
<form id="login" class="panel"><div class="row" style="margin-bottom:26px"><div class="mark">EG</div><div><strong>Escape Gallery</strong><div class="eyebrow" style="margin:0">Operations hub</div></div></div><h1>Ready when you are.</h1><p class="muted">Connect to the controller to follow the room and manage the experience.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button type="button" id="connect" class="primary">Connect to operations</button><p id="loginError" class="notice" role="alert" style="margin-top:16px"></p><p class="small muted" style="margin:18px 0 0">Your token stays in this tab’s memory. Lock the hub when you leave this device.</p></form>
|
||||
<div id="shell" hidden><aside class="sidebar"><div class="brand"><div class="mark">EG</div><div><strong id="siteName">Escape Gallery</strong><small>OPERATIONS</small></div></div><nav aria-label="Main navigation"><div class="nav-label">WORKSPACE</div><button class="nav" data-view="home"><span>◈</span>Hub overview</button><div class="nav-label">CURRENT ROOM</div><button class="nav" data-view="room"><span>▤</span>Room control</button><button class="nav" data-view="cabinets"><span>▣</span>Cabinets</button><button class="nav" data-view="hardware"><span>⌘</span>Hardware</button><button class="nav" data-view="activity"><span>≡</span>Activity</button></nav><footer><p id="sidebarRoom">Glitch: the Dark Machine</p><p>Building network · Operator access</p><button class="lock">Lock this device</button></footer></aside><div class="content"><header class="topbar"><div><small id="breadcrumb">Operations / Overview</small><strong id="roomName">Glitch: the Dark Machine</strong></div><div class="row"><span id="status" class="badge" role="status">Connecting</span><button id="refresh">Refresh</button><button class="lock" id="mobileLock">Lock</button></div></header><main><div id="error" class="notice" role="alert"></div><div id="toast" class="notice" role="status"></div><div id="stale" class="notice" hidden>Connection lost. Showing last received state; commands are disabled.</div>
|
||||
<section data-page="home"><div class="heading"><div class="eyebrow">Your operations, together</div><h1>Keep the experience moving.</h1><p>Open a room, check what needs attention, and get back to your players.</p></div><div class="row between"><h2>Experiences</h2><span class="badge">Local network</span></div><div id="experiences" class="grid"></div><div class="stats"><div class="stat"><strong id="onlineCount">—</strong><span>Cabinets online</span></div><div class="stat"><strong id="playingCount">—</strong><span>Games in progress</span></div><div class="stat"><strong id="readyCount">—</strong><span>Ready to start</span></div><div class="stat"><strong id="attentionCount">—</strong><span>Need attention</span></div></div><div class="columns"><section class="panel"><div class="row between"><h2>Needs attention</h2><button class="link" data-go="cabinets">View cabinets →</button></div><div id="attention"></div></section><section class="panel"><div class="row between"><h2>Recent activity</h2><button class="link" data-go="activity">View all →</button></div><ol id="recent" class="activity"></ol></section></div></section>
|
||||
<section data-page="room" hidden><div class="heading"><div class="eyebrow">Game-master workspace</div><h1 id="roomHeading">Glitch: the Dark Machine</h1><p>Follow the room layout and cabinet sequence. Select a station for its live reported state and controls.</p></div><div class="columns"><section class="panel"><h2>Scenario preparation</h2><label for="scenario">Scenario</label><select id="scenario" style="width:100%"></select><ol id="scenarioPlan" class="scenario-plan"></ol><button id="prepareScenario" class="primary" style="width:100%">Prepare assigned cabinets…</button><p class="small muted" style="margin-top:14px">Preparation can run configured activation outputs. Linked cabinets wait for the preceding win; independent cabinets are started individually.</p><div id="scenarioBlockers" class="notice"></div></section><section class="panel"><h2>Room layout</h2><svg id="map" role="group" aria-label="Room layout; select a cabinet for controls"></svg><div class="legend"><span><i style="background:#287c58"></i>Ready / won</span><span><i style="background:#426ca4"></i>Playing</span><span><i style="background:#9a762d"></i>Waiting / idle</span><span><i style="background:#89918d"></i>Offline</span></div><p class="small muted">Device markers are reference locations, not live sensor readings. Cabinet rings indicate pending or unconfirmed output commands.</p></section></div><div class="row between" style="margin:24px 0 14px"><h2 style="margin:0">Cabinet status</h2><button class="link" data-go="cabinets">Open controls →</button></div><div class="grid" id="roomCards"></div></section>
|
||||
<section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
|
||||
<section data-page="hardware" hidden><div class="heading"><div class="eyebrow">Physical devices</div><h1>Hardware & outputs</h1><p>Trigger configured actions and review acknowledgements. Accepted does not prove that a lock moved or a reward was delivered.</p></div><section class="panel"><label for="hardwareCabinet">Cabinet</label><select id="hardwareCabinet"></select><p id="hardwareHint" class="small muted" style="margin-top:14px"></p><div class="grid" id="actions"></div><h2 style="margin-top:26px">Command history</h2><div class="table-wrap" id="output"></div><h2 style="margin-top:24px">Live device feedback</h2><div id="feedback" class="feedback-list"></div></section><section class="panel" style="margin-top:20px"><div class="row between"><h2>Room devices</h2><span class="badge">Integration pending</span></div><p class="small muted">Device feedback is listed above when a configured Pi bridge reports it. These room markers remain reference locations until mapped to sensor IDs.</p><div id="roomDevices" class="grid"></div></section></section>
|
||||
<section data-page="activity" hidden><div class="heading"><div class="eyebrow">Room history</div><h1>Activity</h1><p>Cabinet events, operator interventions, and command acknowledgements in one place.</p></div><section class="panel"><div class="row filters"><label class="visually-hidden" for="activityCabinet">Filter cabinet</label><select id="activityCabinet"></select><label class="visually-hidden" for="activitySearch">Search activity</label><input type="search" id="activitySearch" placeholder="Search events or actions…"><span id="eventCount" class="small muted"></span></div><ol class="activity" id="events"></ol><p class="small muted" style="margin-top:20px">Latest retained history: up to 100 events per cabinet. Operator entries record actions and reasons; individual staff accounts are not configured.</p></section></section>
|
||||
</main></div></div><dialog id="confirmDialog" aria-labelledby="confirmTitle"><form id="confirmForm"><div class="eyebrow">Review action</div><h2 id="confirmTitle"></h2><p id="confirmText" class="muted"></p><div id="confirmReason" hidden><label for="reason">Reason for this intervention</label><textarea id="reason" rows="3" maxlength="200"></textarea></div><div class="row"><button type="button" id="cancelConfirm">Cancel</button><button type="submit" class="primary" id="acceptConfirm">Confirm</button></div></form></dialog>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id), phases={idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
|
||||
let token='',state=null,selected='',view='home',connected=false,pending=false,refreshing=false,epoch=0,confirmation=null;
|
||||
const node=(tag,text='',cls='')=>{const n=document.createElement(tag);n.textContent=text;n.className=cls;return n};
|
||||
const name=id=>state?.cabinets[id]?.definition.name||id, gameName=id=>state?.catalog.games[id]?.name||id||'No game assigned';
|
||||
const busy=c=>c.commands.some(x=>x.status==='queued'), prep=c=>c.online&&!busy(c)&&['idle','won','lost','needs_prepare'].includes(c.phase);
|
||||
const hardware=c=>connected&&!pending&&c?.online&&c.gpio==='real'&&c.session&&!busy(c)&&['ready','playing','won','lost'].includes(c.phase);
|
||||
function options(id,items){const s=$(id),old=s.value,key=JSON.stringify(items);if(s.dataset.options===key)return;s.dataset.options=key;s.replaceChildren(...items.map(([v,t])=>{const o=node('option',t);o.value=v;return o}));if(items.some(x=>x[0]===old))s.value=old}
|
||||
function cached(id,key,fn){const e=$(id),s=JSON.stringify(key);if(e.dataset.key!==s){e.dataset.key=s;fn(e)}}
|
||||
function navigate(next){view=['home','room','cabinets','hardware','activity'].includes(next)?next:'home';document.querySelectorAll('[data-page]').forEach(e=>e.hidden=e.dataset.page!==view);document.querySelectorAll('[data-view]').forEach(e=>e.setAttribute('aria-current',e.dataset.view===view?'page':'false'));$('breadcrumb').textContent='Operations / '+({home:'Overview',room:'Room control',cabinets:'Cabinets',hardware:'Hardware',activity:'Activity'}[view]);if(state)render()}
|
||||
function select(id,next='cabinets'){selected=id;navigate(next)}
|
||||
async function api(path,data={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),6000);try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:controller.signal});const b=await r.json();if(!r.ok)throw Error(b.error||'Request failed');return b}finally{clearTimeout(timer)}}
|
||||
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
|
||||
async function refresh(){if(!token||pending||refreshing)return;refreshing=true;const generation=epoch;try{const s=await api('/operator/state');if(generation!==epoch)return;state=s;connected=true;$('login').hidden=true;$('shell').hidden=false;$('loginError').textContent='';render()}catch(e){if(generation!==epoch)return;connected=false;$('loginError').textContent=e.message;enable()}finally{refreshing=false;$('status').textContent=connected?'Connected':'Disconnected';$('status').className='badge '+(connected?'good':'bad');$('stale').hidden=connected;enable()}}
|
||||
async function command(action,data){if(!connected||pending)return;pending=true;const generation=++epoch;enable();$('error').textContent='';$('toast').textContent='';try{const result=await api('/operator/command',{action,...data});if(generation!==epoch)return;state=result;render();$('toast').textContent='Command submitted. Follow its acknowledgement in Activity.'}catch(e){if(generation!==epoch)return;$('error').textContent=e.name==='AbortError'?'Reply timed out. Check Activity before repeating the action.':e.message}finally{pending=false;enable();refresh()}}
|
||||
function review(title,text,fn,reason=false){confirmation=fn;$('confirmTitle').textContent=title;$('confirmText').textContent=text;$('confirmReason').hidden=!reason;$('reason').value='';$('reason').required=reason;$('reason').minLength=reason?3:0;$('confirmDialog').showModal()}
|
||||
function allEvents(){return Object.entries(state.cabinets).flatMap(([id,c])=>c.history.map((e,i)=>({...e,id,i}))).sort((a,b)=>b.time-a.time||b.i-a.i)}
|
||||
function events(id,list){cached(id,list,e=>{e.replaceChildren(...list.map(x=>{const li=node('li');li.append(node('time',new Date(x.time*1000).toLocaleTimeString()),node('strong',name(x.id)),node('span',x.message,'message'));return li}));if(!list.length)e.append(node('li','No events yet.','empty'))})}
|
||||
function cards(id){const root=$(id);for(const [key,c] of Object.entries(state.cabinets)){let b=Array.from(root.children).find(e=>e.dataset.cabinet===key);if(!b){b=node('button','','card');b.dataset.cabinet=key;b.onclick=()=>select(key);root.append(b)}b.setAttribute('aria-pressed',String(key===selected));const signature=JSON.stringify([c.online,c.phase,c.game,c.definition.name]);if(b.dataset.signature!==signature){b.dataset.signature=signature;b.replaceChildren(node('strong',name(key)),node('span',c.online?(phases[c.phase]||c.phase):'Offline','badge '+(c.online?'good':'')),node('p',gameName(c.game),'game'))}}}
|
||||
function home(){const cs=Object.values(state.cabinets);$('onlineCount').textContent=cs.filter(c=>c.online).length+' / '+cs.length;$('playingCount').textContent=cs.filter(c=>c.online&&c.phase==='playing').length;$('readyCount').textContent=cs.filter(c=>c.online&&c.phase==='ready').length;const attention=Object.entries(state.cabinets).filter(([,c])=>!c.online||c.phase==='needs_prepare'||c.commands.some(x=>['failed','unknown'].includes(x.status)));$('attentionCount').textContent=attention.length;cached('experiences',state.hub,e=>{const b=node('button','','experience');b.append(node('span','ARCADE ROOM','eyebrow'),node('h2',state.hub.room_name),node('p','Room layout, cabinet controls and hardware activity'),node('span','Open room →','link'));b.onclick=()=>navigate('room');e.replaceChildren(b);for(const s of state.hub.services){const a=node('a','','experience');a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';a.append(node('span',s.kind.toUpperCase(),'eyebrow'),node('h2',s.name),node('p',s.description),node('small','External service · status not monitored'));e.append(a)}});cached('attention',attention.map(([id,c])=>[id,c.online,c.phase]),e=>{e.replaceChildren(...attention.map(([id,c])=>{const b=node('button',name(id)+' — '+(!c.online?'Offline':c.phase==='needs_prepare'?'Needs preparation':'Review command history'),'attention');b.onclick=()=>select(id);return b}));if(!attention.length)e.append(node('p','All cabinets are reporting normally.','muted'))});events('recent',allEvents().slice(0,6))}
|
||||
function scenario(){const s=state.catalog.scenarios[$('scenario').value];$('scenarioPlan').replaceChildren(...(s?.assignments||[]).map(a=>{const li=node('li');li.append(node('strong',name(a.cabinet)+' · '+gameName(a.game)),node('p',a.activate_after_win?'After '+name(a.activate_after_win)+' wins':'Ready for operator start'),node('small',Object.entries(a.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(' · ')));return li}));$('scenarioBlockers').textContent=(s?.assignments||[]).filter(a=>!prep(state.cabinets[a.cabinet])).map(a=>name(a.cabinet)+': must be online, reset, and free of pending commands').join('. ')}
|
||||
function map(){const layout=state.catalog.layout||{},cs=Object.entries(state.cabinets);cached('map',[layout,cs.map(([id,c])=>[id,c.online,c.phase,c.commands])],svg=>{svg.replaceChildren();const pts=[...(layout.boundary||[]),...(layout.walls||[]).flat(),...(layout.devices||[]).map(d=>d.position),...cs.map(([,c])=>c.definition.position)].filter(Boolean);if(!pts.length)return;const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]),minX=Math.min(...xs)-.7,minY=Math.min(...ys)-.7,w=Math.max(...xs)-minX+.7,h=Math.max(...ys)-minY+.7;svg.setAttribute('viewBox',[minX,minY,w,h].join(' '));const draw=(tag,attrs,parent=svg)=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);parent.append(e);return e};if(layout.boundary?.length)draw('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#edf0e9',stroke:'#b6beb5','stroke-width':.04});for(const wall of layout.walls||[])draw('polyline',{points:wall.map(p=>p.join(',')).join(' '),fill:'none',stroke:'#728176','stroke-width':.07});for(const d of layout.devices||[]){if(!d.position)continue;const e=draw('rect',{x:d.position[0]-.05,y:d.position[1]-.05,width:.1,height:.1,fill:'#555'});draw('title',{},e).textContent=d.name+' — reference location; feedback unavailable'}for(const[id,c]of cs){const p=c.definition.position;if(!p)continue;const g=draw('g',{role:'button',tabindex:0,'aria-label':name(id)+': '+(c.online?c.phase:'offline')});g.style.cursor='pointer';g.onclick=()=>select(id);g.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();select(id)}};draw('circle',{cx:p[0],cy:p[1],r:.17,fill:!c.online?'#89918d':c.phase==='playing'?'#426ca4':['ready','won'].includes(c.phase)?'#287c58':'#9a762d'},g);const output=c.commands.filter(x=>['output','reward'].includes(x.type)).at(-1);if(output&&['queued','unknown','failed'].includes(output.status)){const ring=draw('circle',{cx:p[0],cy:p[1],r:.23,fill:'none',stroke:output.status==='queued'?'#b28934':'#bd4a3e','stroke-width':.035},g);draw('title',{},ring).textContent='Output command '+output.status+'; physical state unverified'}draw('text',{x:p[0],y:p[1]-.3,'text-anchor':'middle','font-size':.16,fill:'#263f32'},g).textContent=name(id)}})}
|
||||
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','ready','playing','won'].map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
|
||||
function hardwareView(){renderFeedback();const c=state.cabinets[selected];$('hardwareCabinet').value=selected;$('hardwareHint').textContent=c?name(selected)+' · GPIO: '+c.gpio+' · '+(hardware(c)?'Configured outputs available.':'Requires an online, prepared cabinet with real GPIO and no pending commands.'):'No cabinet selected';cached('actions',[selected,c?.definition.actions],e=>{e.replaceChildren(...(c?.definition.actions||[]).map(action=>{const box=node('div','','panel'),b=node('button','Trigger…');b.dataset.output=action;b.onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Trigger '+action+'?',name(id)+' will run this configured physical action. Check the connected device before proceeding.',reason=>{const bytes=crypto.getRandomValues(new Uint8Array(16));command('trigger_output',{cabinet:id,expected_session:session,action_id:action,reason,request_id:Array.from(bytes,x=>x.toString(16).padStart(2,'0')).join('')})},true)};box.append(node('h3',action),node('p','Configured cabinet action','small muted'),b);return box}));if(!e.children.length)e.append(node('p','No actions configured.','empty'))});cached('output',[selected,c?.commands],e=>{const list=(c?.commands||[]).filter(x=>['reward','output'].includes(x.type)).slice().reverse();e.replaceChildren(...list.map(x=>node('p',(x.action_id||x.type)+' · '+(x.source==='operator'?'Operator':'Scenario / win output')+' · '+x.status+(x.status==='accepted'?' — physical delivery unconfirmed':''))));if(!list.length)e.append(node('p','No hardware commands yet.','muted'))});cached('roomDevices',state.catalog.layout?.devices,e=>e.replaceChildren(...(state.catalog.layout?.devices||[]).map(d=>{const n=node('div','','panel');n.append(node('strong',d.name),node('p',d.id+' · feedback unavailable','small muted'));return n})))}
|
||||
function activity(){const id=$('activityCabinet').value,q=$('activitySearch').value.toLowerCase();const list=allEvents().filter(e=>(!id||e.id===id)&&(!q||(e.message+' '+name(e.id)).toLowerCase().includes(q)));$('eventCount').textContent=list.length+' events';events('events',list)}
|
||||
function render(){if(!state?.cabinets)return;if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];const hub=state.hub||{name:'Escape Gallery',room_name:'Glitch: the Dark Machine',services:[]};state.hub=hub;$('siteName').textContent=hub.name;for(const id of ['sidebarRoom','roomName','roomHeading'])$(id).textContent=hub.room_name;document.title=hub.name+' · Operations';options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name||id]));options('hardwareCabinet',Object.keys(state.cabinets).map(id=>[id,name(id)]));options('activityCabinet',[['','All cabinets'],...Object.keys(state.cabinets).map(id=>[id,name(id)])]);if(view==='home')home();if(view==='room'){scenario();map();cards('roomCards')}if(view==='cabinets'){cards('cards');detail()}if(view==='hardware')hardwareView();if(view==='activity')activity();enable()}
|
||||
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
|
||||
|
||||
const PACMAN_ART={"screen": "data:image/png;base64,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 truncated
|
||||
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost'].includes(c.phase)?phases[c.phase]:'Waiting for operator'});}
|
||||
function renderFeedback(target='feedback'){const c=state.cabinets[selected];const f=c?.feedback;cached(target,[selected,f,c?.online],e=>{e.replaceChildren();if(!f?.devices?.length){e.append(node('p',f?.error||'No configured device feedback received.','muted'));return}for(const d of f.devices){const box=node('div','','feedback-item'+(d.value===true?' active':'')+(!c.online||!f.fresh||d.age_ms>6000?' stale':''));box.append(node('strong',d.name||d.id),node('p',(d.port||'')+' · '+d.kind+' · '+(!c.online||!f.fresh||d.age_ms>6000?'STALE / unavailable':d.error||(typeof d.value==='boolean'?(d.value?'Active':'Inactive'):String(d.value))),'small'));if(d.kind==='output')box.append(node('small','Electrical output state; physical movement is unconfirmed.'));e.append(box)}})}
|
||||
</script></body></html>
|
||||
@@ -3,7 +3,9 @@
|
||||
import argparse
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
@@ -12,6 +14,42 @@ from urllib.parse import urlparse
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
def hub_config(path=None):
|
||||
default = dict(name="Escape Gallery", room_name="Glitch: the Dark Machine", services=[])
|
||||
if path is None:
|
||||
return default
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Hub configuration must be an object")
|
||||
for key in ("name", "room_name"):
|
||||
value = data.get(key, default[key])
|
||||
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 80:
|
||||
raise ValueError("Invalid hub " + key)
|
||||
default[key] = value.strip()
|
||||
entries = data.get("services", [])
|
||||
if not isinstance(entries, list) or len(entries) > 40:
|
||||
raise ValueError("Hub services must be a list of up to 40 entries")
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict): raise ValueError("Invalid hub service")
|
||||
ident, name, kind, url = (entry.get(k, "") for k in ("id", "name", "kind", "url"))
|
||||
if not isinstance(ident, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", ident) or ident in seen:
|
||||
raise ValueError("Invalid or duplicate hub service ID")
|
||||
seen.add(ident)
|
||||
if not isinstance(name, str) or not 1 <= len(name) <= 80 or kind not in ("room", "trivia", "service"):
|
||||
raise ValueError("Invalid hub service name or kind")
|
||||
if not isinstance(url, str): raise ValueError("Invalid hub service URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query or any(c.isspace() or ord(c) < 32 for c in url) or "\\" in url:
|
||||
raise ValueError("Use an HTTP(S) service URL without credentials, query or fragment")
|
||||
try: parsed.port
|
||||
except ValueError: raise ValueError("Invalid hub service port")
|
||||
description = entry.get("description", "")
|
||||
if not isinstance(description, str) or len(description) > 240: raise ValueError("Invalid service description")
|
||||
default["services"].append(dict(id=ident, name=name, kind=kind, url=url, description=description))
|
||||
return default
|
||||
|
||||
|
||||
class Room:
|
||||
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
|
||||
self.clock = clock
|
||||
@@ -25,9 +63,13 @@ class Room:
|
||||
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.feedback = {}
|
||||
self.feedback_at = 0
|
||||
self.feedback_seen = []
|
||||
self.last_seen = 0
|
||||
self.boot = self.state.get("boot", "")
|
||||
self.version = "unknown"
|
||||
self.features = []
|
||||
self.gpio = "unknown"
|
||||
self.state.setdefault("game", "pacman")
|
||||
self.state.setdefault("mappings", {"game_won": ["coin_release"]})
|
||||
@@ -47,8 +89,42 @@ class Room:
|
||||
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)
|
||||
version=self.version, gpio=self.gpio, features=self.features,
|
||||
last_seen=self.last_seen, protocol=2, feedback=self.feedback_snapshot())
|
||||
|
||||
def feedback_snapshot(self):
|
||||
result = dict(self.feedback)
|
||||
age = max(0, self.clock() - self.feedback_at)
|
||||
result['fresh'] = bool(result.get('fresh') and self.online() and age < 4)
|
||||
result['devices'] = [dict(d, age_ms=d['age_ms'] + int(age*1000)) for d in result.get('devices', [])]
|
||||
return result
|
||||
|
||||
def receive_feedback(self, payload):
|
||||
if not isinstance(payload, dict): return
|
||||
devices, events = payload.get('devices', []), payload.get('events', [])
|
||||
if not isinstance(devices, list) or len(devices) > 32 or not isinstance(events, list) or len(events) > 64: return
|
||||
clean = []
|
||||
for d in devices:
|
||||
if not isinstance(d, dict) or d.get('kind') not in ('input', 'output', 'rfid'): continue
|
||||
ident = d.get('id')
|
||||
if not isinstance(ident, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', ident): continue
|
||||
value = d.get('value')
|
||||
if value is not None and type(value) is not bool and not isinstance(value, str): continue
|
||||
age = d.get('age_ms', 999999)
|
||||
if type(age) not in (int, float) or not math.isfinite(age) or age < 0: continue
|
||||
clean.append(dict(id=ident, name=str(d.get('name',ident))[:100], port=str(d.get('port',''))[:8], kind=d['kind'], value=value[:64] if isinstance(value,str) else value, error=str(d.get('error',''))[:120], age_ms=min(int(age),999999)))
|
||||
fresh = payload.get('fresh') is True and self.gpio == 'real'
|
||||
self.feedback = dict(devices=clean, fresh=fresh)
|
||||
self.feedback_at = self.clock()
|
||||
if not fresh: return
|
||||
known = {d['id'] for d in clean}
|
||||
for event in events:
|
||||
if not isinstance(event, dict): continue
|
||||
ident = event.get('id')
|
||||
if not isinstance(ident,str) or len(ident)>100 or ident in self.feedback_seen or event.get('device') not in known: continue
|
||||
self.feedback_seen.append(ident)
|
||||
self.feedback_seen = self.feedback_seen[-256:]
|
||||
self.log('Device '+event['device']+': '+str(event.get('error') or event.get('value'))[:120])
|
||||
|
||||
def expire(self):
|
||||
for command in self.state["commands"]:
|
||||
@@ -65,7 +141,7 @@ class Room:
|
||||
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"))
|
||||
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"), game_config=self.state.get("game_config", {}))
|
||||
self.state["commands"].append(command)
|
||||
self.log("Queued " + kind)
|
||||
return command
|
||||
@@ -82,6 +158,8 @@ class Room:
|
||||
reward="not requested", commands=[], seen=[])
|
||||
self.command(action)
|
||||
elif action == "start":
|
||||
if self.state.get("game_config", {}).get("rfid_credit_device"):
|
||||
raise ValueError("Scan a card and press Start on the cabinet for this RFID scenario")
|
||||
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
|
||||
self.state["phase"] = "starting"
|
||||
self.command("start")
|
||||
@@ -103,7 +181,9 @@ class Room:
|
||||
self.state["boot"] = boot
|
||||
self.last_seen = self.clock()
|
||||
self.version = str(data.get("version", "unknown"))[:80]
|
||||
self.features = data.get("features", []) if isinstance(data.get("features", []), list) else []
|
||||
self.gpio = str(data.get("gpio", "unknown"))[:100]
|
||||
self.receive_feedback(data.get("feedback", {}))
|
||||
self.expire()
|
||||
reports = data.get("reports", [])
|
||||
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
|
||||
@@ -170,7 +250,8 @@ class Room:
|
||||
|
||||
class Fleet:
|
||||
"""Editor catalog plus independently acknowledged cabinet sessions."""
|
||||
def __init__(self, database, catalog, clock=time.time):
|
||||
def __init__(self, database, catalog, clock=time.time, hub=None):
|
||||
self.hub = hub if hub is not None else hub_config()
|
||||
self.catalog = catalog
|
||||
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
|
||||
|
||||
@@ -187,7 +268,7 @@ class Fleet:
|
||||
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)
|
||||
return dict(first, cabinets=cabinets, catalog=self.catalog, hub=self.hub)
|
||||
|
||||
def validate_assignment(self, item):
|
||||
cabinet, game = item.get("cabinet"), item.get("game")
|
||||
@@ -195,6 +276,39 @@ class Fleet:
|
||||
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")
|
||||
settings = item.get("game_config", {})
|
||||
if not isinstance(settings, dict): raise ValueError("game_config must be an object")
|
||||
limits = {"test_auto_win_seconds": (0.1, 300)}
|
||||
limits.update({"galaga": {"score_to_win": (0, 1000000), "stages_to_win": (0, 100)},
|
||||
"frogger": {"homes_to_win": (0, 5), "boards_to_win": (0, 99)},
|
||||
"pacman": {"boards_to_win": (0, 100), "score_to_win": (0, 1000000)}}.get(game, {}))
|
||||
credit_device = settings.get("rfid_credit_device")
|
||||
if credit_device is not None:
|
||||
if game != "pacman" or not isinstance(credit_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", credit_device):
|
||||
raise ValueError("Invalid RFID credit device")
|
||||
if not any(d.get("id") == credit_device and d.get("kind") == "rfid" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
|
||||
raise ValueError("RFID credit device is missing from the cabinet PCB inventory")
|
||||
if "rfid_credit_v1" not in self.rooms[cabinet].features:
|
||||
raise ValueError("Update the game export: RFID credits require arcade-cabinet-2.3")
|
||||
if self.rooms[cabinet].gpio != "real":
|
||||
raise ValueError("RFID credits require real GPIO feedback")
|
||||
if item.get("activate_after_win"):
|
||||
raise ValueError("RFID credit scenarios require player Start, not automatic start")
|
||||
if "rfid_allowed_uids" in settings:
|
||||
uids = settings["rfid_allowed_uids"]
|
||||
if credit_device is None or not isinstance(uids, list) or len(uids) > 100 or any(not isinstance(uid, str) or not re.fullmatch(r"[0-9A-F]{8}", uid) for uid in uids):
|
||||
raise ValueError("RFID allowed UIDs must be a list of uppercase 8-digit hexadecimal IDs")
|
||||
for key, value in settings.items():
|
||||
if key in ("rfid_credit_device", "rfid_allowed_uids"): continue
|
||||
if key not in limits or type(value) not in (int, float) or not math.isfinite(value):
|
||||
raise ValueError("Unsupported game objective: " + str(key))
|
||||
low, high = limits[key]
|
||||
if not low <= value <= high or (key != "test_auto_win_seconds" and value != int(value)):
|
||||
raise ValueError("Invalid game objective: " + key)
|
||||
if settings and not self.rooms[cabinet].online():
|
||||
raise ValueError(f"{cabinet} is offline; check its controller URL, cabinet ID and token before preparing objectives")
|
||||
if settings and "assignment_config_v1" not in self.rooms[cabinet].features:
|
||||
raise ValueError(f"Update the game export on {cabinet}: connected agent {self.rooms[cabinet].version} does not advertise assignment objectives (requires arcade-cabinet-2.1)")
|
||||
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"):
|
||||
@@ -210,7 +324,13 @@ class Fleet:
|
||||
|
||||
def operator(self, action, data=None):
|
||||
data = data or {}
|
||||
if action == "prepare_scenario":
|
||||
if "expected_session" in data:
|
||||
cabinet = data.get("cabinet")
|
||||
if cabinet not in self.rooms or self.rooms[cabinet].state["session"] != data["expected_session"]:
|
||||
raise ValueError("Cabinet session changed; review its current state before sending a command")
|
||||
if action == "trigger_output":
|
||||
self.trigger_output(data)
|
||||
elif action == "prepare_scenario":
|
||||
scenario_id = data.get("scenario")
|
||||
scenario = self.catalog["scenarios"].get(scenario_id)
|
||||
if not scenario: raise ValueError("Unknown scenario")
|
||||
@@ -256,10 +376,47 @@ class Fleet:
|
||||
self.advance()
|
||||
return self.snapshot()
|
||||
|
||||
def trigger_output(self, data):
|
||||
cabinet, action_id = data.get("cabinet"), data.get("action_id")
|
||||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||||
room = self.rooms[cabinet]
|
||||
request_id = data.get("request_id", "")
|
||||
reason = data.get("reason", "")
|
||||
if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,100}", request_id):
|
||||
raise ValueError("Manual hardware command requires a unique request ID")
|
||||
if not isinstance(reason, str) or not 3 <= len(reason.strip()) <= 200:
|
||||
raise ValueError("Enter a reason (3–200 characters) for the hardware action")
|
||||
if data.get("expected_session") != room.state["session"]:
|
||||
raise ValueError("Cabinet session changed; review before triggering hardware")
|
||||
if action_id not in self.catalog["cabinets"][cabinet]["actions"]:
|
||||
raise ValueError("Hardware action is not configured for this cabinet")
|
||||
receipts = room.state.setdefault("manual_requests", {})
|
||||
if request_id in receipts:
|
||||
if receipts[request_id] != [action_id, room.state["session"]]:
|
||||
raise ValueError("Request ID was already used for another action/session")
|
||||
return # A lost HTTP reply must not cause a second physical action.
|
||||
if len(receipts) >= 10000:
|
||||
raise ValueError("Manual action history limit reached; contact the administrator")
|
||||
room.expire()
|
||||
if not room.online(): raise ValueError("Cabinet is offline; no command sent")
|
||||
if room.gpio != "real": raise ValueError("Cabinet must report real GPIO mode")
|
||||
if not room.state["session"] or room.state["phase"] not in ("ready", "playing", "won", "lost"):
|
||||
raise ValueError("Prepare the cabinet before triggering its hardware")
|
||||
if any(c["status"] == "queued" for c in room.state["commands"]):
|
||||
raise ValueError("Wait for the pending command or its timeout")
|
||||
command = room.command("output")
|
||||
command["action_id"] = action_id
|
||||
command["source"] = "operator"
|
||||
room.state["last_output"] = "requested"
|
||||
receipts[request_id] = [action_id, room.state["session"]]
|
||||
# Refuse new requests rather than evict deduplication receipts during a session.
|
||||
room.log("Operator triggered " + str(action_id) + ": " + reason.strip())
|
||||
room.save()
|
||||
|
||||
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)
|
||||
variant=item.get("variant", "standard"), game_config=item.get("game_config", {}), scenario=scenario)
|
||||
room.state.pop("gate", None)
|
||||
room.state.pop("auto_start", None)
|
||||
room.operator("reset" if item.get("activate_after_win") else "prepare")
|
||||
@@ -363,6 +520,7 @@ def main():
|
||||
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")))
|
||||
parser.add_argument("--hub-config", help="Optional operations hub names and service links")
|
||||
args = parser.parse_args()
|
||||
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
|
||||
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
|
||||
@@ -375,7 +533,11 @@ def main():
|
||||
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)
|
||||
try:
|
||||
hub = hub_config(args.hub_config)
|
||||
except (OSError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
room = Fleet(Path(args.database), catalog, hub=hub)
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user