From d009a1ef5054d122542d8b3e4c7194187a165f22 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 15 May 2026 19:45:53 -0400 Subject: [PATCH] feat(acr-wedge): ACR122U PC/SC -> keyboard wedge daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACR122U is a 13.56 MHz PC/SC (CCID) reader, not HID. Browsers can't talk to PC/SC devices directly, so the kiosk JS can't see ACR122U taps the way it sees a USB-HID reader. This daemon bridges the gap: - Polls the ACR122U via pyscard - Reads UID via the standard ACS GET_UID APDU (FF CA 00 00 00) - Types UID + Enter into the focused window using pyautogui - Debounces re-reads of the same card (2s window) Output format matches FusionClockNfcKiosk._normalize_uid() expectations: colon-separated uppercase hex (04:10:5B:CA:FD:22:90 + Enter). The kiosk JS already has a keyboard-wedge listener (v19.0.3.2.0+), so no server-side or kiosk-side changes needed — wedge.py's keystrokes route through the same handleTap() path as a USB-HID reader, preserving photo verification + penalty + activity log. Setup docs include macOS, Windows, Linux instructions plus launchd/Task Scheduler/systemd snippets for running as a service. Strategic value: with this, ACR122U deployments support UA-Pockets (13.56 MHz DESFire EV3) for single-card door+clock setups in the premium tier of the standard product kit. The 125 kHz EM4100 USB-C HID reader remains the default tier. --- tools/fusion_clock_acr_wedge/README.md | 166 ++++++++++++++ tools/fusion_clock_acr_wedge/requirements.txt | 2 + tools/fusion_clock_acr_wedge/wedge.py | 215 ++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 tools/fusion_clock_acr_wedge/README.md create mode 100644 tools/fusion_clock_acr_wedge/requirements.txt create mode 100644 tools/fusion_clock_acr_wedge/wedge.py diff --git a/tools/fusion_clock_acr_wedge/README.md b/tools/fusion_clock_acr_wedge/README.md new file mode 100644 index 00000000..dcd8896a --- /dev/null +++ b/tools/fusion_clock_acr_wedge/README.md @@ -0,0 +1,166 @@ +# fusion_clock ACR122U Wedge + +Turns an ACR122U (or any PC/SC ACS reader) into a USB-HID-style keyboard wedge so the fusion_clock NFC kiosk can use it. + +## Why this exists + +The ACR122U is a fantastic 13.56 MHz reader (better range than cheap HID readers, reads MIFARE Classic/DESFire/NTAG/FeliCa/ISO15693 — basically everything Ubiquiti UA-Pockets and most enterprise cards). **But it speaks PC/SC (CCID), not HID keyboard.** Browsers can't talk to PC/SC devices directly. + +This daemon bridges the gap: it polls the ACR122U over PC/SC, reads tag UIDs, and types them into the focused window as keystrokes — exactly like a USB-HID reader would. The fusion_clock kiosk page's existing keyboard listener picks them up the same way. + +``` +ACR122U → PC/SC → wedge.py → keystrokes → kiosk page in Chrome +``` + +## Setup + +### macOS + +```bash +brew install swig +pip3 install -r requirements.txt +python3 wedge.py --verbose +``` + +The first time the daemon tries to type a UID, macOS will pop up an **Accessibility permission** prompt. Grant it to Terminal.app (or whatever shell ran the script). This is one-time. + +If `pyscard` fails to install: ensure Xcode command-line tools are installed (`xcode-select --install`). + +### Windows + +```powershell +pip install -r requirements.txt +python wedge.py --verbose +``` + +No swig required — pyscard ships pre-built wheels on Windows. + +### Linux + +```bash +sudo apt install pcscd pcsc-tools libpcsclite-dev swig +sudo systemctl enable --now pcscd +pip3 install -r requirements.txt +python3 wedge.py --verbose +``` + +You may need to run with sudo or add your user to the `plugdev` group. + +## Usage + +```bash +# Quiet mode — just types UIDs on tap +python3 wedge.py + +# Verbose — prints every tap and reader event +python3 wedge.py --verbose + +# Detect-only — prints UIDs but doesn't type them +# (use this to verify the reader is working before granting Accessibility perms) +python3 wedge.py --no-type --verbose +``` + +## Output format + +UIDs are typed as colon-separated uppercase hex, followed by Enter: + +``` +04:10:5B:CA:FD:22:90 +``` + +This matches what `FusionClockNfcKiosk._normalize_uid()` expects, so the kiosk recognizes it without any server-side translation. + +## Debounce + +The daemon won't re-emit the same UID more than once within 2 seconds (so holding a card on the reader doesn't fire repeated clock-ins). Tap a different card, or wait 2 seconds, and the same card will re-emit. + +## Running as a service + +For kiosk deployments, you want this to start at boot and stay running. + +### macOS — LaunchAgent + +Create `~/Library/LaunchAgents/com.nexa.fusion-clock-acr-wedge.plist`: + +```xml + + + + + Label + com.nexa.fusion-clock-acr-wedge + ProgramArguments + + /usr/bin/python3 + /Users/USERNAME/path/to/wedge.py + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/fusion-clock-wedge.log + StandardErrorPath + /tmp/fusion-clock-wedge.err + + +``` + +Load it: +```bash +launchctl load ~/Library/LaunchAgents/com.nexa.fusion-clock-acr-wedge.plist +``` + +### Windows — Task Scheduler + +Create a task to run `python wedge.py` at login. Or use NSSM to install it as a Windows service. + +### Linux — systemd user service + +Create `~/.config/systemd/user/fusion-clock-wedge.service`: + +```ini +[Unit] +Description=Fusion Clock ACR122U Wedge +After=graphical-session.target + +[Service] +ExecStart=/usr/bin/python3 /home/USER/path/to/wedge.py +Restart=always + +[Install] +WantedBy=default.target +``` + +Enable it: +```bash +systemctl --user enable --now fusion-clock-wedge.service +``` + +## Troubleshooting + +**"No PC/SC readers detected"** +- macOS: PC/SC is built into the OS, no service to start. Just make sure the reader is plugged in. +- Linux: `sudo systemctl status pcscd` — make sure it's running. +- Windows: built-in. + +**"pyautogui can't type on macOS"** +- Grant Accessibility permission to your terminal in System Settings → Privacy & Security → Accessibility. + +**"Reader detected but no UID on tap"** +- Try `--verbose` to see error messages. +- The card might not respond to the standard GET_UID APDU. Most 13.56 MHz cards do; some FeliCa variants don't. +- Try `python3 wedge.py --no-type --verbose` to confirm detection works without involving the keyboard layer. + +**Keystrokes appearing in the wrong window** +- pyautogui types into whatever window has focus. Make sure the kiosk page is focused when you tap. +- This is the same behavior as a USB-HID reader — they both depend on the OS focused window. + +## Future packaging + +This is the prototype. For client distribution we'll wrap it in: +- macOS .app bundle (PyInstaller + py2app) +- Windows .exe (PyInstaller) +- Linux AppImage + +Plus autostart and an idle "no reader detected" status indicator. See parent module's todo list. diff --git a/tools/fusion_clock_acr_wedge/requirements.txt b/tools/fusion_clock_acr_wedge/requirements.txt new file mode 100644 index 00000000..3f00f797 --- /dev/null +++ b/tools/fusion_clock_acr_wedge/requirements.txt @@ -0,0 +1,2 @@ +pyscard>=2.0.0 +pyautogui>=0.9.54 diff --git a/tools/fusion_clock_acr_wedge/wedge.py b/tools/fusion_clock_acr_wedge/wedge.py new file mode 100644 index 00000000..2af41e0d --- /dev/null +++ b/tools/fusion_clock_acr_wedge/wedge.py @@ -0,0 +1,215 @@ +#!/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()