# NFC Clock Kiosk — Design **Date:** 2026-05-13 **Module:** `fusion_clock` **Status:** Approved design — pending implementation plan **Pilot scope:** 1 station per company ## Problem `fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that: - Takes ~2 seconds (vs ~10 seconds for name search + PIN) - Reuses the same physical Ubiquiti-issued card the employee uses for doors - Works with gloves, dirty hands, or wet hands (touchscreens fail here) - Captures a silent photo at every tap so managers can spot-check buddy-punching attempts ## Goals 1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap 2. **Single-credential**: same card the employee uses for door access also clocks them in 3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check 4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible 5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged 6. **One-time setup**: enroll once, then employees never touch a setup flow again ## Non-goals - Multi-station / multi-zone clocking (future — pilot is 1 station per company) - Per-station geolocation (one location per company; tablet is implicitly at the company location) - Offline mode (v1 fails loudly on network loss; offline replay is future work) - Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed) - QR code alternate credential (deferred to v1.1 if iPhone-only employees push back) - Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient) ## Architecture decision **Option B: Separate kiosk page, shared backend.** A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched. **Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes. **Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in). ## Hardware decision **Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning: - Built-in NFC antenna on the back, dead-center - IP68, MIL-STD-810H, drop-resistant (shop-floor durable) - Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices) - Knox enables true kiosk lockdown - Pogo dock = magnetic constant power, no cable to yank - 10.1" screen visible from a few feet away (vs 8" on regular Active 5) Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data. ## Data model ### `hr.employee` — new field - `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null - Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80` - Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field ### `res.company` — new field - `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location` - Designates which fusion.clock.location is bound to the NFC kiosk for this company - Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty - Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware) ### `hr.attendance` — new fields - `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in. - `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out. - `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`. ### `ir.config_parameter` — new entries - `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch. - `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one. - `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user. - `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development. ### `res.config.settings` — new view section "NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above. **No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`. ## Backend — controller and endpoints **New file:** `controllers/clock_nfc_kiosk.py` All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`. **Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings). ### `GET /fusion_clock/kiosk/nfc` — page render - Renders the NFC kiosk QWeb template - Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1") - Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group ### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle - `type='jsonrpc'`, `auth='user'` - Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }` - Logic: 1. Normalize UID (uppercase, colon-separated, reject malformed input) 2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID. 3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}` 4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}` 5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently 6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: , latitude: 0, longitude: 0 }` to toggle attendance state 7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = `, distance fields = 0 8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out) 9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}` 10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk) 11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }` ### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment - `type='jsonrpc'`, `auth='user'` - Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }` - Logic: 1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group) 2. Normalize UID 3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: ""}` 4. Write `x_fclk_nfc_card_uid` on the target employee 5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z") 6. Return `{ success: true, employee_name, card_uid }` ### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll - Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic. ## Frontend — kiosk page UX **Files:** - `views/kiosk_nfc_templates.xml` — QWeb template for the page - `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine - `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark) **Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds. ### State machine ``` ┌─── (3s timeout) ─────────────────────────┐ ▼ │ ┌─────────────────────────┐ tap detected ┌────────────────────┐ │ IDLE │ ────────────────► │ PROCESSING │ │ "Tap card to clock │ │ spinner, "Reading"│ │ in or out" │ └────────────────────┘ │ big clock, date, │ │ │ company name │ success / error └─────────────────────────┘ ▼ ▲ ┌─────────────────────────┐ │ │ RESULT │ │ │ green: "Welcome John, │ └─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │ │ red: "Card not │ │ enrolled" │ └─────────────────────────┘ ``` ### IDLE state - Top: company name + current time (HH:MM, updates every second) + date - Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation - Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode) ### PROCESSING state - Brief spinner + "Reading card…" - Mostly imperceptible at typical network latency ### RESULT state — success - Green panel - Large employee avatar on the left - "John Smith" — name in big text - "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today" - Auto-return to IDLE after 3s ### RESULT state — error - Red panel - `card_unknown` → "Card not recognized. See your manager." - `network_error` → "No connection. Please try again." - `debounce` → silent (no UI change to avoid double-tap confusion) - `photo_required` → "Camera unavailable. Ask IT to check the kiosk." - Auto-return to IDLE after 4s ### Web NFC implementation - One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted) - After activation, `NDEFReader.scan()` runs continuously - `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data) - UID format: hex bytes joined by colons, uppercased - If `scan()` throws, restart with a 1-second backoff ### Camera implementation - `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC - Hidden `