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