Files
Odoo-Modules/docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
gsinghpal 48d3e48e61 docs(fusion_clock): NFC clock kiosk design
Design for tap-to-clock NFC kiosk in fusion_clock. Pilot scope: 1
station per company, Samsung Galaxy Tab Active 5 Pro running Web NFC
in Chrome kiosk mode. Reuses Ubiquiti-issued cards. Silent photo
verification via front camera. Backend reuses FusionClockAPI helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:50:00 -04:00

20 KiB
Raw Blame History

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_uidChar, 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_idMany2one 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_photoBinary, attachment=True. Frame captured at clock-in.
  • x_fclk_check_out_photoBinary, 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: <remote_addr>, latitude: 0, longitude: 0 } to toggle attendance state
    7. Write x_fclk_clock_source = 'nfc_kiosk', x_fclk_location_id = <resolved>, 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: "<name>"}
    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 <video> element streams continuously
  • On tap, grab one frame to a <canvas>, encode as JPEG quality 0.7 (~3060 KB), POST as base64 in the same JSON payload as the UID
  • If nfc_photo_required = True and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading

Enroll Mode

  • Tap the bottom-right "⚙" → on-screen numpad password entry → match against fusion_clock.nfc_enroll_password → enter Enroll Mode
  • Enroll Mode UI:
    1. Search input → employee list (uses /fusion_clock/kiosk/nfc/employee_search)
    2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
    3. Tap detected → POST to /enroll → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
    4. "Done" button → exit Enroll Mode → back to IDLE
  • 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)

One-time setup flow (first load on a new tablet)

  1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
  2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
  3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
  4. "Setup complete." → enters IDLE
  • After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)

Mock-tap debug mode

  • Gated by fusion_clock.nfc_kiosk_debug = True
  • When enabled, hidden keyboard shortcut Ctrl+Shift+T fires a mock tap with a configurable UID stored in localStorage
  • Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting

Edge cases & failure modes

Scenario Behavior
Card not enrolled Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change.
Employee disabled (x_fclk_enable_clock=False) "Clock disabled for this account." Activity logged.
Card lost/damaged Manager opens employee form, clears x_fclk_nfc_card_uid, issues new card, re-enrolls via kiosk Enroll Mode.
Card already assigned during enroll "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite.
Tablet offline / WiFi drops Fail loudly: "No connection. Use the portal on your phone." No local cache in v1.
Same card tapped twice within 5s Server-side debounce. Second tap silently ignored.
MIFARE clone attack UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure.
Tablet stolen Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session).
Power outage Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE.
Tablet clock drift Irrelevant. All timestamps come from fields.Datetime.now() server-side. Tablet clock is for display only.
UID format mismatch (Ubiquiti vs Web NFC byte order) Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint.
Camera unavailable while nfc_photo_required=True Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation.

Hardware checklist (per company)

  • Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
  • Samsung official Pogo charging dock — ~$100
  • Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
  • USB-C 30W PSU + cable — ~$25
  • Fully Kiosk Browser commercial license (€10 one-time) OR Samsung Knox Configure ($30/year/device)
  • "TAP HERE" decal for the back of the tablet — DIY/printed sticker

Total: ~$915 per company, one-time.

Provisioning script (one-time per tablet)

Prerequisite — Odoo side (one-time per company):

  • Create a res.users named e.g. kiosk-westin@<domain>, member of fusion_clock.group_fusion_clock_manager
  • Generate a long random password; store it in a password manager
  • Set res.company.x_fclk_nfc_kiosk_location_id for that company to the desired fusion.clock.location
  • Toggle fusion_clock.enable_nfc_kiosk = True and fusion_clock.nfc_photo_required per policy
  • Set fusion_clock.nfc_enroll_password to a 4-digit Enroll Mode password

Tablet side:

  1. Factory reset
  2. Sign in with company Google account
  3. Install Fully Kiosk Browser from Play Store
  4. In Fully Kiosk: set kiosk URL → https://<odoo-domain>/fusion_clock/kiosk/nfc, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
  5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
  6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
  7. Mount on dock

Testing plan

Python unit tests (tests/test_clock_nfc_kiosk.py)

  • Tap with valid UID → attendance toggled, photo saved, activity logged
  • Tap with unknown UID → card_unknown error, no attendance row
  • Tap when x_fclk_enable_clock=Falseclock_disabled error
  • Double-tap same UID within 5s → second is debounced
  • Enroll with conflicting UID → card_already_assigned, no overwrite
  • Enroll with wrong password → 403
  • Tap with no fusion.clock.location configured for company → no_location_configured
  • UID normalization: lowercase input → stored uppercase

Manual smoke tests (real tablet or Android phone for dev)

  • Cold boot → IDLE within 5s
  • Tap → RESULT within 1s
  • Photo attached to attendance record (verify in backend)
  • Enroll Mode password gate works; 60s timeout exits cleanly
  • WiFi disconnect → tap shows "No connection"; reconnect → tap works again
  • Tap own card 5x in fast succession → only one state change (debounce holds)

Dev shortcut

  • Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
  • For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
  • Mock-tap debug mode (Ctrl+Shift+T) lets the UI state machine be tested without any hardware

Soak test (before declaring pilot ready)

  • 24h continuous on the dock
  • Periodic taps every few hours
  • Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts

Future considerations

  • Offline mode — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
  • Multi-station — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
  • QR-code-on-portal alternate credential — for iPhone-only employees who don't want to carry a card. Adds BarcodeDetector to the kiosk page alongside NDEFReader, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
  • Ubiquiti webhook integration — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
  • Native Android kiosk app — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.