Files
room_glitch/tools/pi_gpio/device_feedback.py
T
Jeff Luke c1b2195834 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
2026-10-17 14:53:54 -07:00

115 lines
6.3 KiB
Python

"""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.')