#!/usr/bin/env python3 """ fusion_clock ACR122U Wedge ========================== Turns an ACR122U (or other ACS PC/SC NFC reader) into a USB-HID-style keyboard wedge. Polls the reader for tag presence, reads the UID via the standard PC/SC GET_UID APDU, then types the UID + Enter into the currently focused window. The fusion_clock NFC kiosk page already has a keyboard-wedge listener (since v19.0.3.2.0), so once this daemon is running the kiosk will respond to ACR122U taps exactly like it responds to a USB-C HID reader. Why this exists: ACR122U speaks PC/SC (CCID), not HID. Browsers can't talk to PC/SC devices directly. This daemon is the bridge. Output format: Colon-separated uppercase hex + Enter, e.g. "04:10:5B:CA:FD:22:90\\n" Matches what FusionClockNfcKiosk._normalize_uid() expects. Setup (macOS): brew install swig pip3 install pyscard pyautogui python3 wedge.py --verbose Setup (Windows): pip install pyscard pyautogui python wedge.py --verbose Setup (Linux): sudo apt install pcscd pcsc-tools libpcsclite-dev swig sudo systemctl enable --now pcscd pip3 install pyscard pyautogui python3 wedge.py --verbose macOS note: The first time pyautogui tries to type, macOS will prompt for Accessibility permission. Grant it to Terminal.app (or whatever shell is running this script). Usage: python3 wedge.py # quiet, types on tap python3 wedge.py --verbose # prints each tap python3 wedge.py --no-type # detect-only, no keystrokes # (use for debugging) """ import argparse import sys import time try: from smartcard.System import readers from smartcard.Exceptions import ( NoCardException, CardConnectionException, CardRequestTimeoutException, ) except ImportError: print("ERROR: pyscard is not installed.", file=sys.stderr) print(" Install with: pip3 install pyscard", file=sys.stderr) print(" (macOS may also need: brew install swig)", file=sys.stderr) sys.exit(1) # Standard PC/SC APDU to get the card UID. Works on ACR122U, ACR1252U, # and any other PC/SC reader following the ACS extension spec. GET_UID_APDU = [0xFF, 0xCA, 0x00, 0x00, 0x00] # Reader name fragments we'll match against, in priority order. # ACR122U appears as "ACS ACR122U PICC Interface" on most systems. READER_NAME_HINTS = ("ACR122", "ACR1252", "ACR1281", "ACS ACR") # Don't re-emit the same UID more than once within this window. DEBOUNCE_SECONDS = 2.0 # Poll interval — fast enough to feel instant, slow enough to be quiet. POLL_INTERVAL_SECONDS = 0.15 # Time to wait between reconnect attempts when no reader is plugged in. RECONNECT_INTERVAL_SECONDS = 1.5 def find_reader(verbose=False): """Return the first ACR122U-class reader, or None.""" try: rs = readers() except Exception as e: if verbose: print(f"[wedge] enumerate readers failed: {e}") return None if verbose and not rs: print("[wedge] no PC/SC readers detected") for r in rs: name = str(r) if any(hint in name for hint in READER_NAME_HINTS): return r if verbose and rs: print(f"[wedge] readers present but none match: {[str(r) for r in rs]}") return None def read_uid(connection): """Send GET_UID. Return colon-separated uppercase hex, or None.""" try: data, sw1, sw2 = connection.transmit(GET_UID_APDU) except Exception: return None if (sw1, sw2) != (0x90, 0x00): return None if not data: return None return ":".join(f"{b:02X}" for b in data) def emit_uid(uid, type_keys=True, verbose=False): """Type UID + Enter into the focused window via pyautogui.""" if verbose: print(f"[wedge] TAP {uid}") if not type_keys: return try: import pyautogui except ImportError: print( "[wedge] pyautogui is not installed; cannot type. " "Run: pip3 install pyautogui (or use --no-type for detect-only)", file=sys.stderr, ) return pyautogui.typewrite(uid, interval=0.005) pyautogui.press("enter") def run_loop(args): reader = None last_uid = None last_time = 0.0 while True: # Find / refresh reader handle when one isn't connected. if reader is None: reader = find_reader(verbose=args.verbose) if reader is None: time.sleep(RECONNECT_INTERVAL_SECONDS) continue print(f"[wedge] connected: {reader}") # Try to grab a card-present connection. try: connection = reader.createConnection() connection.connect() # raises NoCardException when no card on antenna except NoCardException: time.sleep(POLL_INTERVAL_SECONDS) continue except (CardConnectionException, CardRequestTimeoutException) as e: if args.verbose: print(f"[wedge] connection error: {e}; will rescan readers") reader = None time.sleep(RECONNECT_INTERVAL_SECONDS) continue except Exception as e: if args.verbose: print(f"[wedge] unexpected connection error: {e}") reader = None time.sleep(RECONNECT_INTERVAL_SECONDS) continue # Card present — read the UID and emit if not a duplicate. uid = read_uid(connection) try: connection.disconnect() except Exception: pass if uid: now = time.time() if uid != last_uid or (now - last_time) > DEBOUNCE_SECONDS: emit_uid(uid, type_keys=not args.no_type, verbose=args.verbose) last_uid = uid last_time = now elif args.verbose: print(f"[wedge] debounce: {uid}") time.sleep(POLL_INTERVAL_SECONDS) def main(): parser = argparse.ArgumentParser( description="ACR122U PC/SC -> keyboard wedge for fusion_clock kiosk." ) parser.add_argument( "-v", "--verbose", action="store_true", help="Print each detected tap.", ) parser.add_argument( "--no-type", action="store_true", help="Detect tags but don't type them (useful for testing the reader).", ) args = parser.parse_args() print("[wedge] fusion_clock ACR wedge starting. Ctrl+C to stop.") if args.no_type: print("[wedge] --no-type: tags will be detected but not typed.") try: run_loop(args) except KeyboardInterrupt: print("\n[wedge] stopped.") sys.exit(0) if __name__ == "__main__": main()