pyautogui's Quartz-based keystroke path often fails on newer macOS because the Python CLI binary doesn't auto-surface in System Settings > Accessibility. User reported the daemon detected taps fine but keystrokes never landed in any window. Switch to AppleScript / System Events on macOS. Permission attaches to whatever terminal/app launched the Python process (Terminal.app, iTerm, etc.) — a familiar named app the user can grant Accessibility to in one click. Combined keystroke + Return in a single osascript call to keep latency ~100ms per tap. Fall back to pyautogui if osascript fails (handles edge cases) and on non-macOS platforms.
255 lines
7.8 KiB
Python
255 lines
7.8 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_macos(uid):
|
|
"""Type via AppleScript / System Events.
|
|
|
|
Permission attaches to the terminal/app that launched this Python
|
|
process — typically Terminal.app, iTerm, or another known GUI app
|
|
that the user can easily find in Accessibility settings. More
|
|
reliable than pyautogui's Quartz path which often fails to surface
|
|
Python in the Accessibility list on newer macOS.
|
|
"""
|
|
import subprocess
|
|
script = (
|
|
'tell application "System Events"\n'
|
|
f' keystroke "{uid}"\n'
|
|
' key code 36\n' # Return
|
|
'end tell'
|
|
)
|
|
result = subprocess.run(
|
|
["osascript", "-e", script],
|
|
capture_output=True, text=True, check=False, timeout=3,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"osascript failed: {result.stderr.strip()}")
|
|
|
|
|
|
def _emit_other(uid):
|
|
"""Type via pyautogui (works on Linux, Windows, and as macOS fallback)."""
|
|
import pyautogui
|
|
pyautogui.typewrite(uid, interval=0.005)
|
|
pyautogui.press("enter")
|
|
|
|
|
|
def emit_uid(uid, type_keys=True, verbose=False):
|
|
"""Type UID + Enter into the focused window."""
|
|
if verbose:
|
|
print(f"[wedge] TAP {uid}")
|
|
if not type_keys:
|
|
return
|
|
|
|
if sys.platform == "darwin":
|
|
try:
|
|
_emit_macos(uid)
|
|
return
|
|
except Exception as e:
|
|
if verbose:
|
|
print(f"[wedge] osascript path failed ({e}); falling back to pyautogui")
|
|
|
|
try:
|
|
_emit_other(uid)
|
|
except ImportError:
|
|
print(
|
|
"[wedge] pyautogui is not installed; cannot type. "
|
|
"Run: pip3 install pyautogui (or use --no-type for detect-only)",
|
|
file=sys.stderr,
|
|
)
|
|
except Exception as e:
|
|
print(f"[wedge] type error: {e}", file=sys.stderr)
|
|
|
|
|
|
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()
|