/* @odoo-module */ // NFC Clock Kiosk — Web NFC + camera + state machine. // Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the // element #nfc_kiosk_root only exists on that page, so the module is // inert elsewhere). (function() { "use strict"; const root = document.getElementById("nfc_kiosk_root"); if (!root) return; // not on the kiosk page const stateContainer = document.getElementById("nfc_state_container"); const photoRequired = root.dataset.photoRequired === "1"; const debugEnabled = root.dataset.debugEnabled === "1"; const locationConfigured = root.dataset.locationConfigured === "1"; const soundsEnabled = root.dataset.soundsEnabled === "1"; // On a known device (set up before) the browser already remembers camera/NFC // permission, so slim the prompt to a simple resume tap. try { if (localStorage.getItem("nfc_setup_done") === "1") { const _h2 = document.querySelector(".nfc-kiosk__setup h2"); const _p = document.querySelector(".nfc-kiosk__setup p"); const _btn = document.getElementById("nfc_setup_start"); if (_h2) _h2.textContent = "Fusion Clock Kiosk"; if (_p) _p.textContent = "Tap to resume."; if (_btn) _btn.textContent = "Tap to resume"; } } catch (e) {} // ────────────────────────────────────────────────────────────── // Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True) // ────────────────────────────────────────────────────────────── let _debugOverlayEl = null; function debugLog(msg) { try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {} if (!debugEnabled) return; if (!_debugOverlayEl) { _debugOverlayEl = document.createElement("div"); _debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;"; document.body.appendChild(_debugOverlayEl); } const line = document.createElement("div"); const ts = new Date().toLocaleTimeString(); line.textContent = "[" + ts + "] " + msg; _debugOverlayEl.appendChild(line); while (_debugOverlayEl.childNodes.length > 40) { _debugOverlayEl.removeChild(_debugOverlayEl.firstChild); } _debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight; } debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window)); // ────────────────────────────────────────────────────────────── // Dominant-hue extraction from company logo // Sets the CSS variable --nfc-h on so SCSS can interpolate // the entire palette from the brand color. Falls back to default // (220 = aurora-blue) if no logo or extraction fails. // ────────────────────────────────────────────────────────────── function rgbToHue(r, g, b) { const rN = r / 255, gN = g / 255, bN = b / 255; const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN); const d = max - min; if (d === 0) return null; // grayscale, no hue info let h; if (max === rN) h = ((gN - bN) / d) % 6; else if (max === gN) h = (bN - rN) / d + 2; else h = (rN - gN) / d + 4; h = Math.round(h * 60); if (h < 0) h += 360; return h; } function extractDominantHue(img) { try { const c = document.createElement("canvas"); const w = c.width = Math.min(img.naturalWidth, 200); const h = c.height = Math.min(img.naturalHeight, 200); const ctx = c.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0, w, h); const data = ctx.getImageData(0, 0, w, h).data; let r = 0, g = 0, b = 0, count = 0; for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; if (a < 128) continue; // skip transparent const red = data[i], green = data[i + 1], blue = data[i + 2]; const lum = (red + green + blue) / 3; if (lum > 235 || lum < 25) continue; // skip near-white/near-black const range = Math.max(red, green, blue) - Math.min(red, green, blue); if (range < 25) continue; // skip near-grays r += red; g += green; b += blue; count++; } if (count < 50) { debugLog("hue extraction: too few colored pixels (" + count + "), using default"); return null; } const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count); const hue = rgbToHue(avgR, avgG, avgB); debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue); return hue; } catch (e) { debugLog("hue extraction failed: " + e.message); return null; } } function applyBrandHue(hue) { if (hue == null) return; document.documentElement.style.setProperty("--nfc-h", String(hue)); } const logoImg = document.getElementById("nfc_company_logo"); if (logoImg) { const tryExtract = () => { const hue = extractDominantHue(logoImg); applyBrandHue(hue); }; if (logoImg.complete && logoImg.naturalWidth) { tryExtract(); } else { logoImg.addEventListener("load", tryExtract); logoImg.addEventListener("error", () => debugLog("logo failed to load")); } } else { debugLog("no company logo on page; using default hue"); } // ────────────────────────────────────────────────────────────── // State machine // ────────────────────────────────────────────────────────────── const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" }; let currentState = STATE.SETUP; function setState(next, payload) { currentState = next; if (next === STATE.IDLE) renderIdle(); else if (next === STATE.PROCESSING) renderProcessing(); else if (next === STATE.RESULT) renderResult(payload); else if (next === STATE.ENROLL) renderEnroll(payload); } // ────────────────────────────────────────────────────────────── // Rendering helpers // ────────────────────────────────────────────────────────────── function renderIdle() { stateContainer.innerHTML = `
Tap your card to clock in or out
`; } function renderProcessing() { stateContainer.innerHTML = `
Reading card
`; } // ────────────────────────────────────────────────────────────── // Clock sounds (Web Audio — synthesized, loud + distinct in/out). // AudioContext is created/resumed on the setup tap (a user gesture), // after which it can play on each clock event. // ────────────────────────────────────────────────────────────── let _audioCtx = null; function unlockAudio() { try { if (!_audioCtx) { const AC = window.AudioContext || window.webkitAudioContext; if (AC) _audioCtx = new AC(); } if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume(); } catch (e) { debugLog("audio: unlock failed " + e.message); } } function _note(freq, startAt, dur, peak, type) { const osc = _audioCtx.createOscillator(); const g = _audioCtx.createGain(); osc.type = type || "sine"; osc.frequency.setValueAtTime(freq, startAt); g.gain.setValueAtTime(0.0001, startAt); g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click) g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay osc.connect(g); g.connect(_audioCtx.destination); osc.start(startAt); osc.stop(startAt + dur + 0.04); } function playClockSound(action) { if (!soundsEnabled || !_audioCtx) return; try { if (_audioCtx.state === "suspended") _audioCtx.resume(); const t = _audioCtx.currentTime; if (action === "clock_out") { // warm descending major triad (G–E–C) — a pleasant "goodbye" _note(783.99, t, 0.20, 0.6, "sine"); // G5 _note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5 _note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5 } else { // bright ascending major triad (C–E–G) — a cheerful "welcome" _note(523.25, t, 0.18, 0.6, "sine"); // C5 _note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5 _note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5 } } catch (e) { debugLog("audio: play failed " + e.message); } } // Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime. function playErrorSound() { if (!soundsEnabled || !_audioCtx) return; try { if (_audioCtx.state === "suspended") _audioCtx.resume(); const t = _audioCtx.currentTime; _note(311.13, t, 0.20, 0.55, "triangle"); // Eb4 _note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong") } catch (e) { debugLog("audio: play failed " + e.message); } } function renderResult(payload) { const isError = payload && payload.error; const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success"; if (isError) { playErrorSound(); stateContainer.innerHTML = `
${escapeHtml(payload.message || "Error")}
`; setTimeout(() => setState(STATE.IDLE), 3000); } else { playClockSound(payload.action); const avatar = payload.employee_avatar_url || ""; const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT"; let hoursLine = ""; if (payload.action === "clock_out") { const mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins / 60); const m = mins % 60; hoursLine = `
Worked ${h}h ${m}m this shift
`; } const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true }); stateContainer.innerHTML = `
${escapeHtml(payload.employee_name)}
${action} at ${time}
${hoursLine}
`; setTimeout(() => { if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) { openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE)); } else { setState(STATE.IDLE); } }, 1800); } } // ────────────────────────────────────────────────────────────── // Enroll Mode // ────────────────────────────────────────────────────────────── let enrollPassword = ""; let enrollSelectedEmployee = null; let pendingEnrollUid = null; // set when programming a just-tapped unknown card let enrollIdleTimer = null; function resetEnrollIdleTimer() { if (enrollIdleTimer) clearTimeout(enrollIdleTimer); enrollIdleTimer = setTimeout(() => { // 60s of inactivity in Enroll Mode → exit exitEnrollMode(); }, 60000); } function exitEnrollMode() { if (enrollIdleTimer) clearTimeout(enrollIdleTimer); enrollIdleTimer = null; if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt) enrollSelectedEmployee = null; pendingEnrollUid = null; setState(STATE.IDLE); } // Fixed PIN pad: renders the panel ONCE, then mutates only the masked // display on each press — no innerHTML rebuild, no replayed entrance // animation. (Fixes the per-digit "screen refresh" bug.) function mountPinPad(opts) { let pin = ""; stateContainer.innerHTML = `

${escapeHtml(opts.title || "Enter PIN")}

${[1,2,3,4,5,6,7,8,9].map(n => ``).join("")}
`; const displayEl = stateContainer.querySelector("#nfc_pin_display"); const paint = () => { displayEl.textContent = "•".repeat(pin.length); }; paint(); stateContainer.querySelectorAll(".numpad button").forEach(btn => { btn.addEventListener("click", () => { resetEnrollIdleTimer(); const n = btn.dataset.n; if (n === "back") { pin = pin.slice(0, -1); paint(); } else if (n === "ok") { if (pin.length) opts.onOk(pin); } else { pin += n; paint(); } }); }); stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel); } // Reactive flow: an unknown card was tapped — offer to program it now. function renderUnknownCard(uid) { playErrorSound(); currentState = STATE.RESULT; // block taps while this prompt is up if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; } const autoCancel = setTimeout(() => { if (currentState === STATE.RESULT) setState(STATE.IDLE); }, 8000); stateContainer.innerHTML = `

This card isn't programmed yet

Program it now, or ask a manager.

`; stateContainer.querySelector("#uc_program").addEventListener("click", () => { clearTimeout(autoCancel); pendingEnrollUid = uid; enrollSelectedEmployee = null; // If a manager already unlocked, skip the PIN; otherwise ask for it. setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" }); }); stateContainer.querySelector("#uc_cancel").addEventListener("click", () => { clearTimeout(autoCancel); setState(STATE.IDLE); }); } function renderEnroll(payload) { const phase = (payload && payload.phase) || "password"; resetEnrollIdleTimer(); if (phase === "password") { mountPinPad({ title: "Manager PIN", onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); }, onCancel: exitEnrollMode, }); return; } if (phase === "manager") { stateContainer.innerHTML = `

Manage employees

`; const searchEl = document.getElementById("mgr_search"); const listEl = document.getElementById("mgr_list"); let confirmDeleteId = null; let debounceTimer = null; async function refresh() { resetEnrollIdleTimer(); let emps = []; try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; } catch (e) { listEl.innerHTML = `
Connection error.
`; return; } if (!emps.length) { listEl.innerHTML = `
No employees found.
`; return; } listEl.innerHTML = emps.map(e => { const tag = e.card_uid ? `● ${escapeHtml(e.card_uid)}` : `○ no tag`; const actions = (confirmDeleteId === e.id) ? ` ` : ` ${e.card_uid ? `` : ""} `; return `
${escapeHtml(e.name)}${escapeHtml(e.department || "")} ${tag}
${actions}
`; }).join(""); listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => { resetEnrollIdleTimer(); const id = parseInt(btn.dataset.id, 10); const act = btn.dataset.act; if (act === "assign") { enrollSelectedEmployee = { id, name: btn.dataset.name }; pendingEnrollUid = null; renderEnroll({ phase: "tap" }); } else if (act === "photo") { openPhotoCapture(id, btn.dataset.name, () => renderEnroll({ phase: "manager" })); } else if (act === "clear") { try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {} refresh(); } else if (act === "del") { confirmDeleteId = id; refresh(); } else if (act === "delno") { confirmDeleteId = null; refresh(); } else if (act === "delok") { try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {} confirmDeleteId = null; refresh(); } })); } searchEl.addEventListener("input", () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(refresh, 200); }); document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" })); document.getElementById("mgr_close").addEventListener("click", exitEnrollMode); refresh(); return; } if (phase === "employee") { stateContainer.innerHTML = `

Who is this card for?

`; const searchEl = document.getElementById("enroll_search"); const listEl = document.getElementById("enroll_list"); let debounceTimer = null; searchEl.addEventListener("input", () => { resetEnrollIdleTimer(); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value }); listEl.innerHTML = (result.employees || []).map(e => `
${escapeHtml(e.name)} · ${escapeHtml(e.department || "")}
` ).join(""); listEl.querySelectorAll(".employee-row").forEach(row => { row.addEventListener("click", () => { chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name }); }); }); }, 200); }); searchEl.focus(); document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" })); document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); return; } if (phase === "new_employee") { stateContainer.innerHTML = `

New employee

`; const nameEl = document.getElementById("new_emp_name"); const msgEl = document.getElementById("new_emp_msg"); nameEl.addEventListener("input", resetEnrollIdleTimer); nameEl.focus(); const doCreate = async () => { resetEnrollIdleTimer(); const nm = nameEl.value.trim(); if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; } msgEl.textContent = "Creating…"; let res; try { res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword }); } catch (e) { msgEl.textContent = "No connection. Try again."; return; } if (res.error) { msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN." : res.error === "invalid_name" ? "Enter a valid name." : "Could not create employee."; return; } chooseEmployee({ id: res.employee_id, name: res.employee_name }); }; document.getElementById("new_emp_create").addEventListener("click", doCreate); nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); }); document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" })); return; } if (phase === "tap") { stateContainer.innerHTML = `

Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card

⌐■

Hold the card to the back of the tablet

`; document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); return; } if (phase === "result") { const ok = !payload.error; const msg = ok ? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}` : (payload.error === "invalid_password" ? "Wrong Manager PIN. Try again." : payload.error === "card_already_assigned" ? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.` : `Enroll failed: ${escapeHtml(payload.error)}`); stateContainer.innerHTML = `

${msg}

${ok && payload.employee_id ? `` : ""}
`; if (ok && payload.employee_id) { document.getElementById("enroll_photo").addEventListener("click", () => { openPhotoCapture(payload.employee_id, payload.employee_name, () => { if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode(); }); }); } document.getElementById("enroll_another").addEventListener("click", () => { enrollSelectedEmployee = null; pendingEnrollUid = null; renderEnroll({ phase: ok ? "employee" : "password" }); }); document.getElementById("enroll_done").addEventListener("click", exitEnrollMode); } } // Existing employee picked → if we already hold a tapped UID, bind it now // (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step. function chooseEmployee(emp) { if (pendingEnrollUid) { doEnroll(emp.id, emp.name, pendingEnrollUid, false); } else { enrollSelectedEmployee = emp; renderEnroll({ phase: "tap" }); } } // Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already // held by someone else triggers a reassign confirm rather than a hard error. async function doEnroll(empId, empName, uid, force) { resetEnrollIdleTimer(); let result; try { result = await postJson("/fusion_clock/kiosk/nfc/enroll", { employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force, }); } catch (e) { renderEnroll({ phase: "result", employee_name: empName, error: "network" }); return; } if (result.error === "card_already_assigned" && !force) { renderReassignConfirm(empId, empName, uid, result.existing_employee); return; } renderEnroll({ phase: "result", employee_name: empName, ...result }); } function renderReassignConfirm(empId, empName, uid, existingName) { resetEnrollIdleTimer(); stateContainer.innerHTML = `

Reassign card?

This card belongs to ${escapeHtml(existingName || "another employee")}. Move it to ${escapeHtml(empName)}?

`; stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true)); stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" })); } async function _onEnrollTap(uid) { if (!enrollSelectedEmployee) return; doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false); } // ⚙ button → enter Enroll Mode (only when unlocked) const settingsBtn = document.getElementById("nfc_settings_btn"); if (settingsBtn) { settingsBtn.addEventListener("click", () => { if (kioskLocked || currentState !== STATE.IDLE) return; enrollSelectedEmployee = null; pendingEnrollUid = null; // Already unlocked → reuse that PIN, open the manager page (no re-prompt). setState(STATE.ENROLL, { phase: "manager" }); }); } // ────────────────────────────────────────────────────────────── // Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden. // A manager long-presses the bottom-right corner and enters the Manager // PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after // inactivity. Unlock is in-memory only, so every reload starts locked. // ────────────────────────────────────────────────────────────── let kioskLocked = true; let relockTimer = null; const lockBtn = document.getElementById("nfc_lock_btn"); const unlockBtn = document.getElementById("nfc_unlock_btn"); function applyLockState() { if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex"; if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex"; if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none"; } function armRelock() { if (relockTimer) clearTimeout(relockTimer); relockTimer = setTimeout(() => { relockTimer = null; if (currentState === STATE.IDLE) lockKiosk(); else armRelock(); // don't re-lock mid-enroll }, 120000); } function lockKiosk() { kioskLocked = true; enrollPassword = ""; // forget the PIN → unlocking requires it again if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; } applyLockState(); } function unlockKiosk() { kioskLocked = false; applyLockState(); armRelock(); } if (lockBtn) lockBtn.addEventListener("click", lockKiosk); function openUnlockPin() { currentState = STATE.RESULT; // block card taps during PIN entry mountPinPad({ title: "Manager PIN — unlock", onOk: async (pin) => { let ok = false; try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {} if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); } else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; } }, onCancel: () => setState(STATE.IDLE), }); } if (unlockBtn) unlockBtn.addEventListener("click", () => { if (kioskLocked && currentState === STATE.IDLE) openUnlockPin(); }); applyLockState(); // start locked function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // ────────────────────────────────────────────────────────────── // Clock display (centered top: time with AM/PM + date) // ────────────────────────────────────────────────────────────── function updateClock() { const now = new Date(); let hours = now.getHours(); const ampm = hours >= 12 ? "PM" : "AM"; hours = hours % 12; if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock const hh = String(hours).padStart(2, "0"); const mm = String(now.getMinutes()).padStart(2, "0"); const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); const timeEl = document.getElementById("nfc_clock_time"); const dateEl = document.getElementById("nfc_clock_date"); if (timeEl) { // Render hh:mm + AM/PM as separate spans so SCSS can style them differently timeEl.innerHTML = `${hh}:${mm}${ampm}`; } if (dateEl) dateEl.textContent = dateStr; } updateClock(); setInterval(updateClock, 1000); // Keep-alive: refresh the session every 4 min so the kiosk login never expires. setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000); // ────────────────────────────────────────────────────────────── // Setup wizard // ────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────── // Web NFC reader // ────────────────────────────────────────────────────────────── let ndefReader = null; let nfcReady = false; async function startNfcReader() { debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window)); if (!("NDEFReader" in window)) { throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android."); } ndefReader = new NDEFReader(); debugLog("startNfcReader: ndefReader created, calling scan()..."); await ndefReader.scan(); debugLog("startNfcReader: scan() resolved ✓"); ndefReader.addEventListener("reading", onNfcReading); ndefReader.addEventListener("readingerror", (ev) => { debugLog("readingerror event fired"); console.warn("[nfc-kiosk] reading error; reader still active"); }); nfcReady = true; 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); } } // ────────────────────────────────────────────────────────────── // Local wedge daemon SSE listener. // // If a `wedge.py` daemon is running on this machine (used for // ACR122U / PC/SC readers that can't emit keystrokes themselves), // it exposes a Server-Sent Events stream at // http://localhost:8765/events that pushes each detected UID. // // Chrome treats http://localhost as a secure origin, so an HTTPS // kiosk page can connect to it without mixed-content blocking. // No keystroke injection, no Accessibility permission needed, // no focused-window dependency. // // Routes the UID through the same handleTap()/_onEnrollTap() flow // as Web NFC and USB HID — so photo, penalty, activity log all // fire identically. // ────────────────────────────────────────────────────────────── const WEDGE_SSE_URL = "http://localhost:8765/events"; let _wedgeEs = null; function startWedgeSseListener() { try { _wedgeEs = new EventSource(WEDGE_SSE_URL); _wedgeEs.addEventListener("message", (ev) => { const uid = (ev.data || "").trim().toUpperCase(); if (!uid) return; debugLog("wedge SSE: " + 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); } }); _wedgeEs.addEventListener("open", () => { debugLog("wedge SSE: connected to " + WEDGE_SSE_URL); }); _wedgeEs.addEventListener("error", () => { // EventSource auto-reconnects; this fires on every // dropped connection. Log first occurrence only. if (!_wedgeEs._loggedError) { debugLog("wedge SSE: connection error (daemon may not be running) — will auto-retry"); _wedgeEs._loggedError = true; } }); debugLog("startWedgeSseListener: subscribed to " + WEDGE_SSE_URL); } catch (e) { debugLog("startWedgeSseListener: failed to start — " + e.message); } } 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 || ""; const uid = rawSerial.toUpperCase(); const recCount = (event.message && event.message.records) ? event.message.records.length : 0; debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState); if (!uid) { debugLog(" → IGNORED: empty serialNumber"); return; } if (currentState === STATE.ENROLL) { debugLog(" → routing to _onEnrollTap"); window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid); return; } if (currentState !== STATE.IDLE) { debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")"); return; } debugLog(" → calling handleTap(" + uid + ")"); handleTap(uid); } async function handleTap(uid) { debugLog("handleTap: uid=" + uid); setState(STATE.PROCESSING); let photoB64 = ""; try { photoB64 = await capturePhoto(); debugLog("handleTap: photo captured, size=" + photoB64.length); } catch (e) { debugLog("handleTap: photo capture failed: " + e.message); console.warn("[nfc-kiosk] camera capture failed", e); } try { debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap..."); const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 }); debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200)); if (result.error === "debounce") { setState(STATE.IDLE); return; } if (result.error === "card_unknown") { renderUnknownCard(uid); return; } setState(STATE.RESULT, result); } catch (e) { debugLog("handleTap: POST failed: " + e.message); setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." }); } } async function postJson(url, params) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }), }); const json = await res.json(); return json.result || {}; } // ────────────────────────────────────────────────────────────── // Camera // ────────────────────────────────────────────────────────────── let cameraStream = null; const videoEl = document.getElementById("nfc_camera_feed"); const canvasEl = document.getElementById("nfc_camera_canvas"); async function startCamera() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error("Camera not supported on this browser/device."); } cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } }, audio: false, }); videoEl.srcObject = cameraStream; await videoEl.play(); } async function capturePhoto() { if (!videoEl || !canvasEl || !videoEl.videoWidth) return ""; const w = videoEl.videoWidth; const h = videoEl.videoHeight; canvasEl.width = w; canvasEl.height = h; const ctx = canvasEl.getContext("2d"); ctx.drawImage(videoEl, 0, 0, w, h); return canvasEl.toDataURL("image/jpeg", 0.7); } // ────────────────────────────────────────────────────────────── // Guided profile-photo capture (for employees with no picture). // Live camera + oval face guide → Capture → preview → Use/Retake → // saves a centered 512² square to the employee's profile image. // ────────────────────────────────────────────────────────────── function _captureProfileSquare(video) { const vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) return ""; const side = Math.min(vw, vh); const sx = (vw - side) / 2, sy = (vh - side) / 2; const c = document.createElement("canvas"); c.width = 512; c.height = 512; c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512); return c.toDataURL("image/jpeg", 0.85); } function openPhotoCapture(employeeId, employeeName, onDone) { currentState = STATE.RESULT; // block card taps during capture if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; } const finish = () => { if (onDone) onDone(); }; if (!cameraStream) { finish(); return; } // no camera → skip silently let captured = ""; const renderPreview = () => { stateContainer.innerHTML = `

Use this photo?

`; document.getElementById("photo_retake").addEventListener("click", renderLive); document.getElementById("photo_use").addEventListener("click", async () => { try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {} finish(); }); }; function renderLive() { stateContainer.innerHTML = `

Take ${escapeHtml(employeeName)}'s photo

Center the face in the oval
`; const stage = document.getElementById("photo_stage"); const v = document.createElement("video"); v.autoplay = true; v.muted = true; v.playsInline = true; v.className = "nfc-photo-video"; v.srcObject = cameraStream; stage.insertBefore(v, stage.firstChild); v.play().catch(() => {}); document.getElementById("photo_capture").addEventListener("click", () => { captured = _captureProfileSquare(v); if (captured) renderPreview(); else finish(); }); document.getElementById("photo_skip").addEventListener("click", finish); } renderLive(); } // ────────────────────────────────────────────────────────────── // Wake Lock — keeps the screen on while the kiosk page is active. // Released automatically on tab close/navigation; re-acquired on // visibilitychange when the page comes back to the foreground. // ────────────────────────────────────────────────────────────── let wakeLock = null; async function acquireWakeLock() { if (!("wakeLock" in navigator)) { debugLog("wakeLock: API not supported on this browser"); return; } if (wakeLock) { debugLog("wakeLock: already held, skipping"); return; } try { wakeLock = await navigator.wakeLock.request("screen"); debugLog("wakeLock: acquired ✓ (screen will stay on)"); wakeLock.addEventListener("release", () => { debugLog("wakeLock: released by browser/OS"); wakeLock = null; }); } catch (e) { debugLog("wakeLock: request failed: " + (e && e.message)); } } document.addEventListener("visibilitychange", async () => { if (document.visibilityState === "visible") { debugLog("visibility: visible — re-acquiring wakeLock"); await acquireWakeLock(); } else { debugLog("visibility: " + document.visibilityState); } }); // ────────────────────────────────────────────────────────────── // Setup wizard activation // ────────────────────────────────────────────────────────────── const setupBtn = document.getElementById("nfc_setup_start"); if (setupBtn) { setupBtn.addEventListener("click", async () => { debugLog("setup button clicked"); unlockAudio(); // user gesture → unlock Web Audio for clock sounds // 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(); 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(); // Local wedge daemon SSE listener (for ACR122U / PC/SC readers). startWedgeSseListener(); // 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 = `

Setup failed

${escapeHtml(camErr.message)}

Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.

`; return; } console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr); } await acquireWakeLock(); try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {} setState(STATE.IDLE); debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓"); }); } // ────────────────────────────────────────────────────────────── // Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True) // ────────────────────────────────────────────────────────────── if (debugEnabled) { document.addEventListener("keydown", (e) => { if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) { e.preventDefault(); const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01"; const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored); if (!uid) return; localStorage.setItem("nfc_mock_uid", uid); if (currentState === STATE.ENROLL) { _onEnrollTap(uid.toUpperCase()); } else if (currentState === STATE.IDLE) { handleTap(uid.toUpperCase()); } } }); console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap"); } window.__nfcKiosk = { setState, STATE, photoRequired, debugEnabled, locationConfigured, handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19) }; })();