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_idregistration_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 openhttp://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/statusPOST /api/configPOST /api/registerPOST /api/sync-timePOST /api/poll-jobPOST /api/clear-provisioningPOST /api/cancel-local-jobPOST /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:
setupwaiting approvalidlesyncing timeheartbeatchecking jobsready to writewriting cardreporting taperror
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:
- Poll for a job.
- If a job is returned, enter
ready to writemode and show the target user/label on the ESP dashboard. - On the next card tap, write
write_payloadto the card if supported by the chosen RFID card type. - 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/readersPOST /api/v1/esp/admin/readersmanual reader/key creation fallbackPUT /api/v1/esp/admin/readers/{reader_id}POST /api/v1/esp/admin/readers/{reader_id}/approvePOST /api/v1/esp/admin/readers/{reader_id}/rejectDELETE /api/v1/esp/admin/readers/{reader_id}POST /api/v1/esp/device/heartbeatGET /api/v1/esp/admin/cardsPOST /api/v1/esp/admin/cardsPUT /api/v1/esp/admin/cards/{card_id}GET /api/v1/esp/admin/write-jobs?limit=100POST /api/v1/esp/admin/write-jobsPOST /api/v1/esp/admin/write-jobs/{job_id}/cancelGET /api/v1/esp/admin/taps?limit=100GET /api/v1/esp/admin/attendance?open_only=false&limit=100POST /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.