feat(fusion_clock): USB HID reader support + desktop-tolerant kiosk setup

The NFC kiosk previously required Web NFC, which is Android-Chrome-only.
This blocked desktop testing and locked us to a single hardware path.

Add a keyboard-wedge listener that captures keystrokes from USB HID NFC
readers (the standard Sycreader/Yanzeo class). The listener buffers hex
chars + separators, flushes on Enter (or 600ms idle as fallback for
readers without a terminator), and routes the UID through the same
handleTap()/_onEnrollTap() codepath as Web NFC. Photo verification,
penalty calc, and activity logging all fire identically.

Make the setup button tolerant: try Web NFC, but treat its absence as
non-fatal. USB HID always activates. Only hard-fail when photoRequired
is True AND the camera is unavailable.

Result: same kiosk page now works on Android Chrome (Web NFC), desktop
Chrome with a USB reader, or both at once.

Bump manifest to 19.0.3.2.0.
This commit is contained in:
gsinghpal
2026-05-15 19:30:51 -04:00
parent a24ef15a02
commit 9001b6fc51
2 changed files with 102 additions and 19 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.1.1',
'version': '19.0.3.2.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """

View File

@@ -414,6 +414,74 @@
debugLog("startNfcReader: listeners attached, nfcReady=true");
}
// ──────────────────────────────────────────────────────────────
// USB HID keyboard-wedge listener (works alongside Web NFC).
// Most USB NFC readers in HID mode type the UID as keystrokes and
// end with Enter. We buffer chars until Enter arrives (or 500ms
// pause), then route the UID through the same flow Web NFC uses.
//
// Critical: this listener fires the same handleTap()/_onEnrollTap()
// codepath as Web NFC, so penalty + photo + activity log all work
// identically regardless of which reader produced the UID.
// ──────────────────────────────────────────────────────────────
let _hidBuffer = "";
let _hidLastKeyAt = 0;
let _hidFlushTimer = null;
const HID_RESET_MS = 500; // pause longer than this resets the buffer
const HID_FLUSH_MS = 600; // if no Enter arrives, flush this long after last char
const HID_MIN_LEN = 4; // shortest plausible UID
const HID_CHAR_RE = /^[0-9A-Fa-f:\-]$/; // hex digits + common separators
function _flushHidBuffer() {
const uid = _hidBuffer.trim().toUpperCase();
_hidBuffer = "";
if (_hidFlushTimer) { clearTimeout(_hidFlushTimer); _hidFlushTimer = null; }
if (uid.length < HID_MIN_LEN) {
debugLog("HID flush: too short, ignored (" + JSON.stringify(uid) + ")");
return;
}
debugLog("HID flush: uid=" + uid + " state=" + currentState);
if (currentState === STATE.ENROLL) {
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
} else if (currentState === STATE.IDLE) {
handleTap(uid);
} else {
debugLog(" → IGNORED: state=" + currentState);
}
}
function startUsbHidListener() {
document.addEventListener("keydown", (e) => {
// Don't capture keystrokes inside form inputs — preserves
// typing in enroll-mode search box, etc.
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
return;
}
// Don't fight the existing Ctrl+Shift+T mock-tap shortcut.
if (e.ctrlKey || e.metaKey || e.altKey) return;
const now = Date.now();
if (now - _hidLastKeyAt > HID_RESET_MS) {
_hidBuffer = "";
}
_hidLastKeyAt = now;
if (e.key === "Enter") {
e.preventDefault();
_flushHidBuffer();
return;
}
if (HID_CHAR_RE.test(e.key)) {
_hidBuffer += e.key;
// Fallback flush if the reader doesn't emit Enter
if (_hidFlushTimer) clearTimeout(_hidFlushTimer);
_hidFlushTimer = setTimeout(_flushHidBuffer, HID_FLUSH_MS);
}
});
debugLog("startUsbHidListener: listening for HID keystrokes ✓");
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const rawSerial = event.serialNumber || "";
@@ -547,27 +615,42 @@
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
debugLog("setup button clicked");
// Try Web NFC, but don't fail if absent — USB HID reader is a
// first-class alternative (works on desktops/iOS too).
let webNfcOk = false;
try {
await startNfcReader();
debugLog("setup: NFC ready, starting camera...");
try {
await startCamera();
debugLog("setup: camera ready ✓");
} catch (camErr) {
debugLog("setup: camera failed: " + camErr.message);
if (photoRequired) throw camErr;
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
setState(STATE.IDLE);
} catch (e) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
webNfcOk = true;
debugLog("setup: Web NFC ready ✓");
} catch (webNfcErr) {
debugLog("setup: Web NFC unavailable, continuing with USB HID — " + webNfcErr.message);
}
// USB HID listener: no permission needed, works on any platform.
startUsbHidListener();
// Camera: best-effort unless photoRequired forces it.
try {
await startCamera();
debugLog("setup: camera ready ✓");
} catch (camErr) {
debugLog("setup: camera failed: " + camErr.message);
if (photoRequired) {
// Only THIS path is a hard fail. Use the existing error
// render to keep DOM patterns consistent with the rest
// of this file.
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(camErr.message)}</p>
<p style="opacity:.7;font-size:.9em">Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.</p>
</div>
`;
return;
}
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
setState(STATE.IDLE);
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
});
}