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
+36
-1
@@ -40,7 +40,7 @@ If this fails, fix the reported GPIO permissions/package error or check the resi
|
||||
|
||||
## 4. Start the GPIO helper
|
||||
|
||||
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
|
||||
Copy **pi_gpio_bridge.py** and **device_feedback.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
|
||||
|
||||
```sh
|
||||
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
|
||||
@@ -81,3 +81,38 @@ Keep the helper running while restarting games with Escape. For unattended boot,
|
||||
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
|
||||
|
||||
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
|
||||
|
||||
|
||||
## PCB live feedback
|
||||
|
||||
This optional integration requires the updated controller, launcher, bridge plus `device_feedback.py`, and a new Godot cabinet export (`arcade-cabinet-2.2`). No installation is performed by editing the PCB diagram. Odin remains separate.
|
||||
|
||||
1. Save each cabinet's PCB connections in the Godot editor. Give every device a unique ID. For buttons/sensors, check **Input pull-up** when the switch activates by connecting the signal to GND; verify the actual circuit first.
|
||||
2. Export the catalog from the project root:
|
||||
```sh
|
||||
godot --headless --path . --script res://tools/room_controller/export_catalog.gd
|
||||
```
|
||||
3. Extract that cabinet's inventory (the destination must not already exist):
|
||||
```sh
|
||||
python3 tools/pi_gpio/device_feedback.py --catalog tools/room_controller/catalog.json --cabinet cabinet_01 --output devices.json
|
||||
```
|
||||
4. Copy the inventory and updated software to that cabinet Pi. For RFID, enable SPI using Raspberry Pi configuration and build the separate helper on a Pi with a Rust toolchain:
|
||||
```sh
|
||||
cargo build --release --manifest-path tools/pi_gpio/rfid_helper/Cargo.toml
|
||||
```
|
||||
The binary is `tools/pi_gpio/rfid_helper/target/release/arcade-rfid`. The source follows Odin's MFRC522 library and selector sequence, but this new helper has **not yet been compiled or tested on the tester Pi**. It requires access to GPIO and SPI. Do not run Odin's reader service simultaneously.
|
||||
5. Add these optional settings to the existing launcher's `[gpio]` section, with paths relative to the launcher INI (or absolute paths):
|
||||
```ini
|
||||
devices = /home/YOUR_USER/arcade/devices.json
|
||||
rfid_helper = /home/YOUR_USER/arcade/tools/pi_gpio/rfid_helper/target/release/arcade-rfid
|
||||
pins =
|
||||
```
|
||||
An empty `pins` list is valid for input/RFID-only testing. If outputs are configured, list only their actual BCM pins and keep the corresponding HardwareActions aligned. Omit `rfid_helper` if no RFID readers are configured. Keep the existing real-GPIO launcher setting enabled.
|
||||
|
||||
**Pin ownership:** RFID uses BCM17/27/22 for selection, BCM20 for reset, and SPI0 (BCM8/9/10/11). These cannot also be general outputs. The earlier standalone LED example on BCM17 and start-light example on BCM20 must not be used with this RFID setup. Inputs cannot also be in the output allowlist. The bridge rejects these conflicts before starting.
|
||||
|
||||
The operator's cabinet detail and Hardware views display per-device readings. Activity records changes. Disconnected or old samples become stale. J1/J2 are scanned as selector slots 0/1; additional configured readers are scanned in sequence. RFID values currently use Odin's 32-bit UID representation and are not a promise of a complete UID for every card type. A failed tag read is shown as absent **or unreadable**, rather than proof that a card was removed.
|
||||
|
||||
Outputs report the GPIO device's logical electrical state after initialization, not proof that a lock or motor moved. Physical confirmation needs a separate sensor. I2C/Other devices are not sampled yet. Feedback is monitoring only; automatic scenario rules for these events are not enabled by this change.
|
||||
|
||||
Start with a button and the two readers, then test an appropriately driven output. Confirm real readings and stale/disconnected behavior on the Pi before relying on this for room operation. Windows tests use fake devices; no physical pins were exercised during development.
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Read-only feedback inventory; output writes remain in pi_gpio_bridge.Outputs."""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
SIGNALS = dict(zip(['J10','J11','J12','J13','J14','J15','J17','J18','J19','J20','J21','J22'], [5,6,13,19,26,21,16,12,24,23,18,25]))
|
||||
RESERVED = {17,27,22,20,8,9,10,11}
|
||||
|
||||
def load_devices(path, output_pins):
|
||||
data = json.loads(Path(path).read_text(encoding='utf-8-sig'))
|
||||
if not isinstance(data, list) or len(data)>32: raise ValueError('Device config must be a list of up to 32 devices')
|
||||
ids, ports, inputs, readers = set(), set(), set(), False
|
||||
for d in data:
|
||||
if not isinstance(d,dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}',d.get('id','')): raise ValueError('Invalid device ID')
|
||||
if d['id'] in ids or d.get('port') in ports: raise ValueError('Duplicate device ID or port')
|
||||
ids.add(d['id']); ports.add(d.get('port'))
|
||||
if not isinstance(d.get('name',''),str) or len(d.get('name',''))>100: raise ValueError('Invalid name')
|
||||
kind, port = d.get('kind'), d.get('port')
|
||||
if kind=='rfid':
|
||||
if port not in ['J'+str(i) for i in range(1,9)]: raise ValueError('RFID requires J1-J8')
|
||||
readers=True
|
||||
elif kind in ('input','output'):
|
||||
if port not in SIGNALS: raise ValueError('Digital devices require a signal connector')
|
||||
d['pin']=SIGNALS[port]
|
||||
if kind=='input':
|
||||
if type(d.get('pull_up')) is not bool: raise ValueError('Inputs require explicit pull_up true/false')
|
||||
inputs.add(d['pin'])
|
||||
elif d['pin'] not in output_pins: raise ValueError('Output connector not enabled in output pin allowlist')
|
||||
else: raise ValueError('Supported kinds: input, output, rfid')
|
||||
if inputs & set(output_pins): raise ValueError('A GPIO cannot be both an input and an output')
|
||||
if readers and RESERVED & (set(output_pins)|inputs): raise ValueError('RFID selector/reset/SPI pins cannot be configured as general GPIO')
|
||||
return data
|
||||
|
||||
class Feedback:
|
||||
def __init__(self, definitions, input_factory, outputs, reader_binary=None, clock=time.monotonic):
|
||||
self.definitions, self.outputs, self.clock = definitions, outputs, clock
|
||||
self.lock=threading.RLock(); self.stop=threading.Event(); self.values={}; self.events=deque(maxlen=64)
|
||||
self.boot=uuid.uuid4().hex; self.sequence=0; self.inputs=[]; self.worker=None; self.reader=None
|
||||
try:
|
||||
for d in definitions:
|
||||
self.values[d['id']] = dict(id=d['id'],name=d.get('name',d['id']),port=d['port'],kind=d['kind'],value=None,error='Awaiting first sample',stamp=clock())
|
||||
if d['kind']=='input': self.inputs.append((d,input_factory(d['pin'],pull_up=d['pull_up'],bounce_time=.05)))
|
||||
readers=[d for d in definitions if d['kind']=='rfid']
|
||||
if readers:
|
||||
if not reader_binary: raise ValueError('RFID devices require --rfid-helper executable')
|
||||
self.reader=subprocess.Popen([str(Path(reader_binary).resolve()),','.join(str(int(d['port'][1:])-1) for d in readers)],stdout=subprocess.PIPE,stderr=None,text=True,bufsize=1)
|
||||
self.worker=threading.Thread(target=self._readers,args=(readers,),daemon=True); self.worker.start()
|
||||
except Exception:
|
||||
self.close(); raise
|
||||
def update(self, ident, value, error=''):
|
||||
with self.lock:
|
||||
old=self.values[ident]
|
||||
changed=old['value']!=value or old['error']!=error
|
||||
old.update(value=value,error=error,stamp=self.clock())
|
||||
if changed:
|
||||
self.sequence+=1
|
||||
self.events.append(dict(id=self.boot+':'+str(self.sequence),device=ident,value=value,error=error,kind=old['kind']))
|
||||
def _readers(self, readers):
|
||||
slots={int(d['port'][1:])-1:d['id'] for d in readers}
|
||||
try:
|
||||
for line in self.reader.stdout:
|
||||
if self.stop.is_set(): break
|
||||
if len(line)>2048: continue
|
||||
value=json.loads(line)
|
||||
ident=slots.get(value.get('slot'))
|
||||
if ident:
|
||||
self.update(ident,str(value.get('uid',''))[:32],str(value.get('error',''))[:120])
|
||||
except Exception as exc:
|
||||
for ident in slots.values(): self.update(ident,None,'Reader helper error: '+str(exc)[:80])
|
||||
finally:
|
||||
if not self.stop.is_set():
|
||||
for ident in slots.values(): self.update(ident,None,'Reader helper stopped')
|
||||
def tick(self):
|
||||
for d,device in self.inputs:
|
||||
try: self.update(d['id'],bool(device.value))
|
||||
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
|
||||
with self.outputs.lock:
|
||||
for d in self.definitions:
|
||||
if d['kind']!='output': continue
|
||||
entry=self.outputs.pins.get(d['pin'])
|
||||
try: self.update(d['id'],bool(entry['device'].value) if entry else None,'' if entry else 'Output not initialized')
|
||||
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
|
||||
def snapshot(self):
|
||||
with self.lock:
|
||||
now=self.clock()
|
||||
return dict(devices=[{k:v for k,v in d.items() if k!='stamp'}|{'age_ms':int(max(0,now-d['stamp'])*1000)} for d in self.values.values()],events=list(self.events))
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
if self.reader:
|
||||
self.reader.terminate()
|
||||
try:self.reader.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:self.reader.kill();self.reader.wait()
|
||||
if self.worker:self.worker.join(timeout=2)
|
||||
self.reader.stdout.close()
|
||||
for _,device in self.inputs: device.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser=argparse.ArgumentParser(description='Extract saved PCB feedback inventory from the exported room catalog')
|
||||
parser.add_argument('--catalog',required=True)
|
||||
parser.add_argument('--cabinet',required=True)
|
||||
parser.add_argument('--output',required=True)
|
||||
args=parser.parse_args()
|
||||
catalog=json.loads(Path(args.catalog).read_text(encoding='utf-8-sig'))
|
||||
devices=catalog['cabinets'][args.cabinet].get('devices',[])
|
||||
with Path(args.output).open('x',encoding='utf-8') as file:
|
||||
json.dump(devices,file,indent=2)
|
||||
print('Wrote',len(devices),'device definitions. Review input polarity and output allowlist before running the bridge.')
|
||||
@@ -103,7 +103,7 @@ class Outputs:
|
||||
self.ledger.close()
|
||||
|
||||
|
||||
def handler_for(outputs):
|
||||
def handler_for(outputs, feedback=None):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
try:
|
||||
@@ -115,7 +115,7 @@ def handler_for(outputs):
|
||||
data = json.loads(self.rfile.read(length) or b"{}")
|
||||
if self.path == "/heartbeat":
|
||||
outputs.heartbeat()
|
||||
result = {"ok": True}
|
||||
result = {"ok": True, "feedback": feedback.snapshot() if feedback else {"devices": [], "events": []}}
|
||||
elif self.path == "/output":
|
||||
result = outputs.command(data)
|
||||
print("GPIO:", data, flush=True)
|
||||
@@ -142,14 +142,19 @@ def main():
|
||||
parser.add_argument("--controller", default="cabinet_01_gpio")
|
||||
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
|
||||
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
|
||||
parser.add_argument("--devices", help="Explicit PCB feedback JSON inventory")
|
||||
parser.add_argument("--rfid-helper", help="Built arcade-rfid helper executable")
|
||||
args = parser.parse_args()
|
||||
pins = [int(n) for n in args.pins.split(",")]
|
||||
if not pins or any(pin < 0 or pin > 27 for pin in pins):
|
||||
pins = [int(n) for n in args.pins.split(",") if n.strip()]
|
||||
if len(set(pins)) != len(pins) or any(pin < 0 or pin > 27 for pin in pins):
|
||||
parser.error("Use BCM header GPIO numbers 0..27")
|
||||
from gpiozero import OutputDevice
|
||||
from gpiozero import OutputDevice, Button
|
||||
from device_feedback import Feedback, load_devices
|
||||
definitions = load_devices(args.devices, pins) if args.devices else []
|
||||
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
|
||||
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
|
||||
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
|
||||
feedback = Feedback(definitions, Button, outputs, args.rfid_helper)
|
||||
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs, feedback))
|
||||
server.timeout = .05
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
@@ -158,6 +163,7 @@ def main():
|
||||
def watchdog():
|
||||
while not stop.wait(.02):
|
||||
outputs.tick()
|
||||
feedback.tick()
|
||||
|
||||
worker = threading.Thread(target=watchdog, daemon=True)
|
||||
worker.start()
|
||||
@@ -168,6 +174,7 @@ def main():
|
||||
finally:
|
||||
stop.set()
|
||||
worker.join()
|
||||
feedback.close()
|
||||
outputs.close()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "arcade-rfid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
rppal = "0.22.1"
|
||||
rppal_mfrc522 = "0.4.1"
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,43 @@
|
||||
// Uses the same MFRC522 library and selector sequence as Odin; no Odin room logic.
|
||||
use rppal::{gpio::Gpio, spi::{Bus, Mode, SlaveSelect, Spi}};
|
||||
use rppal_mfrc522::Mfrc522;
|
||||
use std::{thread, time::Duration, io::{self, Write}};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let arg = std::env::args().nth(1).unwrap_or_else(|| "0,1".into());
|
||||
let slots: Vec<usize> = arg.split(',').map(str::parse).collect::<Result<_,_>>()?;
|
||||
anyhow::ensure!(!slots.is_empty() && slots.len() <= 8 && slots.iter().all(|x| *x < 8), "Slots must be 0..7");
|
||||
let gpio = Gpio::new()?;
|
||||
let mut a = gpio.get(17)?.into_output_low();
|
||||
let mut b = gpio.get(27)?.into_output_low();
|
||||
let mut c = gpio.get(22)?.into_output_low();
|
||||
let mut reset = gpio.get(20)?.into_output_low();
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let mut spi = Spi::new(Bus::Spi0, SlaveSelect::Ss0, 1_000_000, Mode::Mode0)?;
|
||||
let mut reader = Mfrc522::new(&mut spi);
|
||||
loop {
|
||||
for slot in &slots {
|
||||
reset.set_low();
|
||||
if slot & 1 != 0 {a.set_high()} else {a.set_low()}
|
||||
if slot & 2 != 0 {b.set_high()} else {b.set_low()}
|
||||
if slot & 4 != 0 {c.set_high()} else {c.set_low()}
|
||||
reset.set_high();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let sample = (|| -> anyhow::Result<serde_json::Value> {
|
||||
reader.reset()?;
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let version = reader.version()?;
|
||||
anyhow::ensure!(version != 0 && version != 255, "Reader did not respond");
|
||||
Ok(match reader.uid(Duration::from_millis(250)) {
|
||||
Ok(uid) => serde_json::json!({"slot":slot,"uid":format!("{:08X}",uid.to_u32()),"error":""}),
|
||||
Err(_) => serde_json::json!({"slot":slot,"uid":"","error":"No tag read (absent or unreadable)"}),
|
||||
})
|
||||
})();
|
||||
reset.set_low();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let value = sample.unwrap_or_else(|e| serde_json::json!({"slot":slot,"uid":"","error":e.to_string()}));
|
||||
println!("{}",value);
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user