Files
Odoo-Modules/tools/fusion_clock_acr_wedge/wedge.py
gsinghpal d009a1ef50 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.
2026-05-15 19:45:53 -04:00

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