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.
216 lines
6.6 KiB
Python
216 lines
6.6 KiB
Python
#!/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()
|