Files
Odoo-Modules/tools/fusion_clock_acr_wedge/wedge.py
gsinghpal d75198be9f fix(acr-wedge): use AppleScript on macOS for keystroke injection
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.
2026-05-15 19:56:49 -04:00

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()