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
@@ -0,0 +1,92 @@
|
||||
@tool
|
||||
class_name CabinetPCBConfig
|
||||
extends Resource
|
||||
|
||||
# This resource describes wiring. It never configures or writes GPIO.
|
||||
@export var connections: Array[PCBConnection] = []
|
||||
|
||||
const TYPES := {
|
||||
"rfid": "RFID reader", "button": "Button / switch", "sensor": "Digital sensor",
|
||||
"led": "LED / lighting driver", "motor": "Motorized switch / motor driver",
|
||||
"relay": "Relay / MOSFET module", "i2c": "I2C peripheral", "other": "Other (document wiring)"
|
||||
}
|
||||
|
||||
static func ports() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for index in range(8):
|
||||
result.append({"id": "J%d" % (index + 1), "family": "reader", "label": "%d%d%d" % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "pins": "Selector: BCM22=%d, BCM27=%d, BCM17=%d\nShared reset: BCM20. Reader connector contact pinout is not documented." % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "types": ["rfid", "other"], "bcm": -1})
|
||||
result.append({"id": "J9", "family": "host", "label": "Pi ribbon", "pins": "Host GPIO ribbon connector. Confirm pin-1 orientation against the physical board.", "types": [], "bcm": -1})
|
||||
var signals := [5, 6, 13, 19, 26, 21]
|
||||
for index in range(6):
|
||||
result.append({"id": "J%d" % (10 + index), "family": "powered", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 3V3 (red) | 3 signal (yellow)\nBCM%d" % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
|
||||
result.append({"id": "J16", "family": "i2c", "label": "I2C1", "pins": "1 GND (black) | 2 3V3 (red) | 3 SCL (white) | 4 SDA (yellow)", "types": ["i2c", "other"], "bcm": -1})
|
||||
signals = [16, 12, 24, 23, 18, 25]
|
||||
for index in range(6):
|
||||
result.append({"id": "J%d" % (17 + index), "family": "signal", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 signal (red)\nBCM%d - red is signal, not a power supply." % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
|
||||
return result
|
||||
|
||||
static func port(id: String) -> Dictionary:
|
||||
for definition in ports():
|
||||
if definition.id == id:
|
||||
return definition
|
||||
return {}
|
||||
|
||||
func connection(id: String) -> PCBConnection:
|
||||
for item in connections:
|
||||
if item != null and item.port_id == id:
|
||||
return item
|
||||
return null
|
||||
|
||||
func set_connection(item: PCBConnection) -> void:
|
||||
for index in range(connections.size() - 1, -1, -1):
|
||||
if connections[index] != null and connections[index].port_id == item.port_id:
|
||||
connections.remove_at(index)
|
||||
if not item.device_type.is_empty():
|
||||
connections.append(item)
|
||||
emit_changed()
|
||||
|
||||
func issues(hardware: HardwareConfig = null) -> PackedStringArray:
|
||||
var messages := PackedStringArray()
|
||||
var used_ports: Dictionary = {}
|
||||
var used_ids: Dictionary = {}
|
||||
for item in connections:
|
||||
if item == null:
|
||||
messages.append("Empty connection resource.")
|
||||
continue
|
||||
var definition := port(item.port_id)
|
||||
if definition.is_empty() or not definition.types.has(item.device_type):
|
||||
messages.append("%s: unsupported connector/device combination." % item.port_id)
|
||||
if used_ports.has(item.port_id):
|
||||
messages.append("%s: connector is assigned more than once." % item.port_id)
|
||||
used_ports[item.port_id] = true
|
||||
if item.device_id.strip_edges().is_empty():
|
||||
messages.append("%s: enter a device ID." % item.port_id)
|
||||
elif used_ids.has(item.device_id):
|
||||
messages.append("Duplicate device ID: %s." % item.device_id)
|
||||
used_ids[item.device_id] = true
|
||||
if item.device_type in ["led", "motor", "relay"] and not item.binding_id.is_empty():
|
||||
var action: HardwareAction = hardware.get_action(StringName(item.binding_id)) if hardware else null
|
||||
if action == null:
|
||||
messages.append("%s: hardware action '%s' does not exist." % [item.port_id, item.binding_id])
|
||||
elif not definition.is_empty() and action.gpio_pin != definition.bcm:
|
||||
messages.append("%s: action uses BCM%d, but connector uses BCM%d." % [item.port_id, action.gpio_pin, definition.bcm])
|
||||
if hardware:
|
||||
for action in hardware.actions:
|
||||
if action != null and action.gpio_pin in [17, 27, 22, 20]:
|
||||
messages.append("Action '%s' uses reserved PCB selector/reset BCM%d." % [action.action_id, action.gpio_pin])
|
||||
return messages
|
||||
|
||||
func feedback_devices() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for item in connections:
|
||||
if item == null:
|
||||
continue
|
||||
var kind := ""
|
||||
if item.device_type == "rfid": kind = "rfid"
|
||||
elif item.device_type in ["button", "sensor"]: kind = "input"
|
||||
elif item.device_type in ["led", "motor", "relay"]: kind = "output"
|
||||
else:
|
||||
push_warning("Feedback driver not implemented for %s (%s)" % [item.device_id, item.device_type])
|
||||
continue
|
||||
result.append({"id": item.device_id, "name": item.display_name, "port": item.port_id, "kind": kind, "pull_up": item.input_pull_up})
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0g0mq4b3d547
|
||||
@@ -10,6 +10,10 @@ extends Resource
|
||||
|
||||
@export var hardware_config: HardwareConfig
|
||||
|
||||
@export_group("PCB Connections")
|
||||
@export var pcb_config: CabinetPCBConfig
|
||||
@export_group("")
|
||||
|
||||
const FEET_TO_METERS: float = 0.3048
|
||||
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@tool
|
||||
class_name PCBConnection
|
||||
extends Resource
|
||||
|
||||
@export var port_id: String = ""
|
||||
@export var device_type: String = ""
|
||||
@export var device_id: String = ""
|
||||
@export var display_name: String = ""
|
||||
@export var binding_id: String = ""
|
||||
@export_multiline var notes: String = ""
|
||||
|
||||
@export var input_pull_up: bool = true
|
||||
@@ -0,0 +1 @@
|
||||
uid://burui20kuayc2
|
||||
@@ -1,5 +1,5 @@
|
||||
extends Node
|
||||
const VERSION := "arcade-cabinet-2"
|
||||
const VERSION := "arcade-cabinet-2.3"
|
||||
var cabinet_id := "cabinet_01"
|
||||
var database: ProjectDatabase = preload("res://data/project_database.tres")
|
||||
var cabinet: CabinetProfile
|
||||
@@ -14,6 +14,14 @@ var busy := false
|
||||
var executing := false
|
||||
var event_number := 0
|
||||
var notice: Label
|
||||
var test_win_remaining := -1.0
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if phase != "playing" or test_win_remaining < 0.0 or game == null: return
|
||||
test_win_remaining -= delta
|
||||
if test_win_remaining <= 0.0:
|
||||
test_win_remaining = -1.0
|
||||
game.win_game()
|
||||
|
||||
func _ready() -> void:
|
||||
var config := ConfigFile.new()
|
||||
@@ -47,9 +55,13 @@ func _ready() -> void:
|
||||
func poll() -> void:
|
||||
if busy or executing: return
|
||||
busy = true
|
||||
if game != null and phase == "ready" and game.has_method("receive_device_feedback") and HardwareManager.real_gpio and HardwareManager.client != null:
|
||||
game.receive_device_feedback(HardwareManager.client.get_feedback())
|
||||
var sent := reports.duplicate(true)
|
||||
var result: Dictionary = await connection.post("/cabinet/poll", {
|
||||
"cabinet": cabinet_id, "protocol": 2, "boot": boot, "version": VERSION,
|
||||
"features": ["assignment_config_v1", "device_feedback_v1", "rfid_credit_v1"],
|
||||
"feedback": HardwareManager.client.get_feedback() if HardwareManager.real_gpio and HardwareManager.client != null else {},
|
||||
"gpio": "real" if HardwareManager.real_gpio else "simulation", "reports": sent,
|
||||
})
|
||||
busy = false
|
||||
@@ -70,6 +82,7 @@ func poll() -> void:
|
||||
notice.text = "Waiting for the room operator" if game == null else ""
|
||||
|
||||
func unload() -> void:
|
||||
test_win_remaining = -1.0
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
if is_instance_valid(game):
|
||||
remove_child(game)
|
||||
@@ -77,6 +90,10 @@ func unload() -> void:
|
||||
game = null
|
||||
|
||||
func event(name: String) -> void:
|
||||
if name == "started" and game != null:
|
||||
test_win_remaining = float(game.run_config.get("test_auto_win_seconds", -1.0))
|
||||
elif name in ["won", "lost"]:
|
||||
test_win_remaining = -1.0
|
||||
event_number += 1
|
||||
reports.append({"kind": "event", "id": boot + ":" + str(event_number), "session": session, "event": name})
|
||||
phase = {"started": "playing", "won": "won", "lost": "lost"}.get(name, phase)
|
||||
@@ -110,7 +127,9 @@ func execute(command: Dictionary) -> void:
|
||||
if str(definition.game_id) == "missile_command": game.set("relative_mouse_aim", true)
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE
|
||||
add_child(game)
|
||||
game.configure({})
|
||||
game.configure(command.get("game_config", {}).duplicate(true))
|
||||
if game.has_method("receive_device_feedback") and HardwareManager.client != null:
|
||||
game.receive_device_feedback(HardwareManager.client.get_feedback())
|
||||
game.game_started.connect(func(): event("started"))
|
||||
game.game_won.connect(func(): event("won"))
|
||||
game.game_lost.connect(func(): event("lost"))
|
||||
|
||||
@@ -7,6 +7,9 @@ const FEET_TO_METERS: float = 0.3048
|
||||
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
|
||||
|
||||
|
||||
@export var is_doorway: bool = false
|
||||
@export var swing_reversed: bool = false
|
||||
|
||||
@export var wall_id: StringName = &""
|
||||
@export var start: Vector2 = Vector2.ZERO
|
||||
@export var end: Vector2 = Vector2(
|
||||
|
||||
@@ -5,6 +5,8 @@ const URL := "http://127.0.0.1:8765"
|
||||
var pending: Array[Dictionary] = []
|
||||
var serial := 0
|
||||
var busy := false
|
||||
var device_feedback: Dictionary = {}
|
||||
var feedback_received_ms: int = 0
|
||||
var heartbeat: HTTPRequest
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -12,6 +14,7 @@ func _ready() -> void:
|
||||
heartbeat = HTTPRequest.new()
|
||||
heartbeat.timeout = 1.0
|
||||
add_child(heartbeat)
|
||||
heartbeat.request_completed.connect(_feedback_response)
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = 1.0
|
||||
timer.timeout.connect(_heartbeat)
|
||||
@@ -56,3 +59,18 @@ func _pump() -> void:
|
||||
status_changed.emit("GPIO %s accepted by Pi bridge" % item.payload.pin if ok else message)
|
||||
completed.emit(item.id, ok)
|
||||
_pump.call_deferred()
|
||||
|
||||
func _feedback_response(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code != 200:
|
||||
return
|
||||
var data = JSON.parse_string(body.get_string_from_utf8())
|
||||
if data is Dictionary and data.get("feedback") is Dictionary:
|
||||
device_feedback = data.feedback
|
||||
feedback_received_ms = Time.get_ticks_msec()
|
||||
|
||||
func get_feedback() -> Dictionary:
|
||||
var result := device_feedback.duplicate(true)
|
||||
result["fresh"] = feedback_received_ms > 0 and Time.get_ticks_msec() - feedback_received_ms < 4000
|
||||
for device in result.get("devices", []):
|
||||
device["age_ms"] = int(device.get("age_ms", 999999)) + maxi(0, Time.get_ticks_msec() - feedback_received_ms)
|
||||
return result
|
||||
@@ -9,6 +9,8 @@ extends Resource
|
||||
@export var variant: StringName = &"standard"
|
||||
## Managed controller: load and auto-start only after this cabinet wins.
|
||||
@export var activate_after_win: StringName = &""
|
||||
## Per-assignment objectives; empty uses the game defaults.
|
||||
@export var game_config: Dictionary = {}
|
||||
|
||||
@export var event_mappings: Array[ScenarioEventMapping] = []
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ func _create_working_copy() -> void:
|
||||
)
|
||||
|
||||
assignment.activate_after_win = source_assignment.activate_after_win
|
||||
assignment.game_config = source_assignment.game_config.duplicate(true)
|
||||
|
||||
assignment.event_mappings = (
|
||||
_duplicate_mappings(
|
||||
@@ -260,6 +261,7 @@ func _capture_scenario_state(
|
||||
"game_id": StringName(),
|
||||
"variant": assignment.variant,
|
||||
"activate_after_win": assignment.activate_after_win,
|
||||
"game_config": assignment.game_config.duplicate(true),
|
||||
"event_mappings": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,8 @@ func _capture_room_state(
|
||||
"start": wall.start,
|
||||
"end": wall.end,
|
||||
"thickness": wall.thickness,
|
||||
"is_doorway": wall.is_doorway,
|
||||
"swing_reversed": wall.swing_reversed,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user