feat(acr-wedge): ACR122U PC/SC -> keyboard wedge daemon
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.
This commit is contained in:
215
tools/fusion_clock_acr_wedge/wedge.py
Normal file
215
tools/fusion_clock_acr_wedge/wedge.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user