Files
sasa-membership/ESP_RFID_API.md
T

10 KiB

ESP RFID Integration Contract

The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs.

All timestamps are UTC ISO-8601 unless otherwise noted.

1. Reader Setup and Approval

Register from the ESP dashboard

Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured.

POST /api/v1/esp/device/register
Content-Type: application/json

Request:

{
  "device_id": "front-desk-01",
  "name": "Front Desk Reader",
  "location": "Reception",
  "reader_type": "checkin_checkout",
  "can_write_cards": true,
  "firmware_version": "esp32-rfid-0.1.0",
  "notes": "PN532 over I2C"
}

Response:

{
  "device_id": "front-desk-01",
  "provisioning_status": "pending",
  "registration_token": "one-time-registration-token",
  "message": "Registration received. Approve this reader in the admin panel.",
  "poll_interval_seconds": 5
}

Firmware must store:

  • device_id
  • registration_token

Admin approval

In the web portal:

  • Dashboard -> Admin -> ESP RFID -> Setup
  • Approve or reject pending readers.

When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below.

Poll provisioning status

Use this while waiting for approval.

GET /api/v1/esp/device/provisioning-status
X-ESP-Device-ID: front-desk-01
X-ESP-Registration-Token: one-time-registration-token

Pending response:

{
  "device_id": "front-desk-01",
  "provisioning_status": "pending",
  "message": "Waiting for admin approval.",
  "api_key": null,
  "poll_interval_seconds": 5
}

Approved response:

{
  "device_id": "front-desk-01",
  "provisioning_status": "provisioned",
  "message": "Reader approved. Store this API key; it will not be returned again.",
  "api_key": "generated-api-key",
  "poll_interval_seconds": 5
}

Firmware must store api_key permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader.

Rejected response:

{
  "device_id": "front-desk-01",
  "provisioning_status": "rejected",
  "message": "Reader registration rejected.",
  "api_key": null,
  "poll_interval_seconds": 5
}

2. Authenticated Reader Requests

After provisioning, every reader endpoint uses:

X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json

Invalid, inactive, unapproved, or rejected readers receive 401.

3. Time Sync

The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed.

3. Local ESP Dashboard

The firmware in ESP/main.cpp serves the local dashboard on port 80.

Access options:

  • If WiFi connects, open http://<reader-lan-ip>/.
  • If WiFi does not connect, the reader starts a setup AP named RFID-Setup-XXXXXX; connect to it and open http://192.168.4.1/.

Default local dashboard credentials are configured in ESP/secrets.h:

  • Username: admin
  • Password: admin

The dashboard shows:

  • WiFi status and RSSI
  • Provisioning status
  • Current operating mode
  • Server-synced clock
  • Last heartbeat result
  • Current write-job state
  • Last card UID/action/result

The local UI also exposes controls for:

  • Saving server/WiFi/reader configuration
  • Registering or re-registering the reader
  • Manually syncing time
  • Manually polling for queued write jobs
  • Clearing provisioning
  • Rebooting

Local dashboard JSON endpoints:

  • GET /api/status
  • POST /api/config
  • POST /api/register
  • POST /api/sync-time
  • POST /api/poll-job
  • POST /api/clear-provisioning
  • POST /api/cancel-local-job
  • POST /api/reboot

These are local ESP endpoints, not SASA backend endpoints.

4. Heartbeat

The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard.

POST /api/v1/esp/device/heartbeat
X-ESP-Device-ID: front-desk-01
X-ESP-API-Key: generated-api-key
Content-Type: application/json

Request:

{
  "mode": "idle",
  "message": "Heartbeat OK",
  "wifi_rssi": -54,
  "free_heap": 184320,
  "firmware_version": "esp32-rfid-0.2.0",
  "active_write_job_id": null
}

Response:

{
  "ok": true,
  "server_time_utc": "2026-05-05T10:15:30.123456",
  "unix_ms": 1777976130123,
  "heartbeat_interval_seconds": 10,
  "time_poll_interval_seconds": 3,
  "write_job_poll_interval_seconds": 3
}

Firmware modes currently used:

  • setup
  • waiting approval
  • idle
  • syncing time
  • heartbeat
  • checking jobs
  • ready to write
  • writing card
  • reporting tap
  • error

The backend updates last_seen_at when heartbeat succeeds.

5. Time Sync

The reader should poll every 3 seconds.

GET /api/v1/esp/device/time

Response:

{
  "server_time_utc": "2026-05-05T10:15:30.123456",
  "unix_ms": 1777976130123,
  "poll_interval_seconds": 3
}

Use unix_ms to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device.

6. Check-In and Check-Out Taps

POST /api/v1/esp/device/taps

Request:

{
  "card_uid": "04A1B2C3D4",
  "tapped_at": "2026-05-05T10:15:30.123456",
  "reader_type": "checkin_checkout"
}

tapped_at is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence.

Accepted check-in:

{
  "accepted": true,
  "action": "check_in",
  "message": "Checked in",
  "server_time_utc": "2026-05-05T10:15:30.123456",
  "tap_id": 123,
  "session_id": 45,
  "user_id": 7,
  "user_name": "Jane Smith",
  "checked_in_at": "2026-05-05T10:15:30.123456",
  "checked_out_at": null,
  "duration_seconds": null
}

Accepted check-out:

{
  "accepted": true,
  "action": "check_out",
  "message": "Checked out",
  "server_time_utc": "2026-05-05T12:20:30.123456",
  "tap_id": 124,
  "session_id": 45,
  "user_id": 7,
  "user_name": "Jane Smith",
  "checked_in_at": "2026-05-05T10:15:30.123456",
  "checked_out_at": "2026-05-05T12:20:30.123456",
  "duration_seconds": 7500
}

Denied:

{
  "accepted": false,
  "action": "denied",
  "message": "Unknown RFID card",
  "server_time_utc": "2026-05-05T10:15:30.123456",
  "tap_id": 125,
  "session_id": null,
  "user_id": null,
  "user_name": null
}

The server logs denied taps so unknown cards can still be audited.

The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu.

7. Queued RFID Card Writes

Admins queue card writes in:

  • Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write

They select:

  • User
  • Writing reader
  • Card label

The reader does not need an inbound network port. It polls for jobs.

Poll next write job

Only readers with can_write_cards=true may use this endpoint.

GET /api/v1/esp/device/write-jobs/next

No job response:

null

Job response:

{
  "id": 20,
  "reader_id": 3,
  "user_id": 7,
  "card_id": null,
  "label": "Jane main card",
  "status": "claimed",
  "requested_by_user_id": 1,
  "card_uid": null,
  "write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}",
  "claimed_at": "2026-05-05T10:15:30.123456",
  "completed_at": null,
  "error_message": null,
  "created_at": "2026-05-05T10:14:00.000000",
  "updated_at": "2026-05-05T10:15:30.123456"
}

Firmware behavior:

  1. Poll for a job.
  2. If a job is returned, enter ready to write mode and show the target user/label on the ESP dashboard.
  3. On the next card tap, write write_payload to the card if supported by the chosen RFID card type.
  4. Report success/failure.

The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key FF FF FF FF FF FF, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs.

Complete write job

POST /api/v1/esp/device/write-jobs/20/complete

Success request:

{
  "success": true,
  "card_uid": "04A1B2C3D4"
}

Failure request:

{
  "success": false,
  "error_message": "Card write failed verification"
}

On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label.

8. ESP Local Dashboard Login

Use this when someone logs into the ESP-hosted dashboard.

POST /api/v1/esp/device/dashboard-login

Request:

{
  "email": "admin@example.com",
  "password": "password"
}

Response:

{
  "valid": true,
  "user_id": 1,
  "role": "admin",
  "user_name": "Admin User"
}

Only admin and super_admin users return valid: true.

9. Admin API Summary

These endpoints use the normal portal JWT.

  • GET /api/v1/esp/admin/readers
  • POST /api/v1/esp/admin/readers manual reader/key creation fallback
  • PUT /api/v1/esp/admin/readers/{reader_id}
  • POST /api/v1/esp/admin/readers/{reader_id}/approve
  • POST /api/v1/esp/admin/readers/{reader_id}/reject
  • DELETE /api/v1/esp/admin/readers/{reader_id}
  • POST /api/v1/esp/device/heartbeat
  • GET /api/v1/esp/admin/cards
  • POST /api/v1/esp/admin/cards
  • PUT /api/v1/esp/admin/cards/{card_id}
  • GET /api/v1/esp/admin/write-jobs?limit=100
  • POST /api/v1/esp/admin/write-jobs
  • POST /api/v1/esp/admin/write-jobs/{job_id}/cancel
  • GET /api/v1/esp/admin/taps?limit=100
  • GET /api/v1/esp/admin/attendance?open_only=false&limit=100
  • POST /api/v1/esp/admin/attendance/close-stale

Stale close request:

{
  "checkout_hour": 17
}

The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks checkout_source as system.

10. Time Contract Summary

  • API responses use UTC/Zulu datetimes.
  • ESP readers sync against /api/v1/esp/device/time.
  • Reader-supplied timestamps are normalized to UTC before storage.
  • The browser frontend displays these values in Europe/London for operators and members.