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>
This commit is contained in:
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# 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: <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 (~30–60 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=False` → `clock_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.
|
||||
Reference in New Issue
Block a user