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:
166
tools/fusion_clock_acr_wedge/README.md
Normal file
166
tools/fusion_clock_acr_wedge/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# fusion_clock ACR122U Wedge
|
||||
|
||||
Turns an ACR122U (or any PC/SC ACS reader) into a USB-HID-style keyboard wedge so the fusion_clock NFC kiosk can use it.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The ACR122U is a fantastic 13.56 MHz reader (better range than cheap HID readers, reads MIFARE Classic/DESFire/NTAG/FeliCa/ISO15693 — basically everything Ubiquiti UA-Pockets and most enterprise cards). **But it speaks PC/SC (CCID), not HID keyboard.** Browsers can't talk to PC/SC devices directly.
|
||||
|
||||
This daemon bridges the gap: it polls the ACR122U over PC/SC, reads tag UIDs, and types them into the focused window as keystrokes — exactly like a USB-HID reader would. The fusion_clock kiosk page's existing keyboard listener picks them up the same way.
|
||||
|
||||
```
|
||||
ACR122U → PC/SC → wedge.py → keystrokes → kiosk page in Chrome
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install swig
|
||||
pip3 install -r requirements.txt
|
||||
python3 wedge.py --verbose
|
||||
```
|
||||
|
||||
The first time the daemon tries to type a UID, macOS will pop up an **Accessibility permission** prompt. Grant it to Terminal.app (or whatever shell ran the script). This is one-time.
|
||||
|
||||
If `pyscard` fails to install: ensure Xcode command-line tools are installed (`xcode-select --install`).
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
python wedge.py --verbose
|
||||
```
|
||||
|
||||
No swig required — pyscard ships pre-built wheels on Windows.
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
sudo apt install pcscd pcsc-tools libpcsclite-dev swig
|
||||
sudo systemctl enable --now pcscd
|
||||
pip3 install -r requirements.txt
|
||||
python3 wedge.py --verbose
|
||||
```
|
||||
|
||||
You may need to run with sudo or add your user to the `plugdev` group.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Quiet mode — just types UIDs on tap
|
||||
python3 wedge.py
|
||||
|
||||
# Verbose — prints every tap and reader event
|
||||
python3 wedge.py --verbose
|
||||
|
||||
# Detect-only — prints UIDs but doesn't type them
|
||||
# (use this to verify the reader is working before granting Accessibility perms)
|
||||
python3 wedge.py --no-type --verbose
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
UIDs are typed as colon-separated uppercase hex, followed by Enter:
|
||||
|
||||
```
|
||||
04:10:5B:CA:FD:22:90<Enter>
|
||||
```
|
||||
|
||||
This matches what `FusionClockNfcKiosk._normalize_uid()` expects, so the kiosk recognizes it without any server-side translation.
|
||||
|
||||
## Debounce
|
||||
|
||||
The daemon won't re-emit the same UID more than once within 2 seconds (so holding a card on the reader doesn't fire repeated clock-ins). Tap a different card, or wait 2 seconds, and the same card will re-emit.
|
||||
|
||||
## Running as a service
|
||||
|
||||
For kiosk deployments, you want this to start at boot and stay running.
|
||||
|
||||
### macOS — LaunchAgent
|
||||
|
||||
Create `~/Library/LaunchAgents/com.nexa.fusion-clock-acr-wedge.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nexa.fusion-clock-acr-wedge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>/Users/USERNAME/path/to/wedge.py</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/fusion-clock-wedge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/fusion-clock-wedge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Load it:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nexa.fusion-clock-acr-wedge.plist
|
||||
```
|
||||
|
||||
### Windows — Task Scheduler
|
||||
|
||||
Create a task to run `python wedge.py` at login. Or use NSSM to install it as a Windows service.
|
||||
|
||||
### Linux — systemd user service
|
||||
|
||||
Create `~/.config/systemd/user/fusion-clock-wedge.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Fusion Clock ACR122U Wedge
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /home/USER/path/to/wedge.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Enable it:
|
||||
```bash
|
||||
systemctl --user enable --now fusion-clock-wedge.service
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No PC/SC readers detected"**
|
||||
- macOS: PC/SC is built into the OS, no service to start. Just make sure the reader is plugged in.
|
||||
- Linux: `sudo systemctl status pcscd` — make sure it's running.
|
||||
- Windows: built-in.
|
||||
|
||||
**"pyautogui can't type on macOS"**
|
||||
- Grant Accessibility permission to your terminal in System Settings → Privacy & Security → Accessibility.
|
||||
|
||||
**"Reader detected but no UID on tap"**
|
||||
- Try `--verbose` to see error messages.
|
||||
- The card might not respond to the standard GET_UID APDU. Most 13.56 MHz cards do; some FeliCa variants don't.
|
||||
- Try `python3 wedge.py --no-type --verbose` to confirm detection works without involving the keyboard layer.
|
||||
|
||||
**Keystrokes appearing in the wrong window**
|
||||
- pyautogui types into whatever window has focus. Make sure the kiosk page is focused when you tap.
|
||||
- This is the same behavior as a USB-HID reader — they both depend on the OS focused window.
|
||||
|
||||
## Future packaging
|
||||
|
||||
This is the prototype. For client distribution we'll wrap it in:
|
||||
- macOS .app bundle (PyInstaller + py2app)
|
||||
- Windows .exe (PyInstaller)
|
||||
- Linux AppImage
|
||||
|
||||
Plus autostart and an idle "no reader detected" status indicator. See parent module's todo list.
|
||||
2
tools/fusion_clock_acr_wedge/requirements.txt
Normal file
2
tools/fusion_clock_acr_wedge/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pyscard>=2.0.0
|
||||
pyautogui>=0.9.54
|
||||
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