forked from jamesp/sasa-membership
Add UTC datetime helpers to attempt to fix running issue
This commit is contained in:
+463
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/provisioning-status
|
||||
X-ESP-Device-ID: front-desk-01
|
||||
X-ESP-Registration-Token: one-time-registration-token
|
||||
```
|
||||
|
||||
Pending response:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"provisioning_status": "pending",
|
||||
"message": "Waiting for admin approval.",
|
||||
"api_key": null,
|
||||
"poll_interval_seconds": 5
|
||||
}
|
||||
```
|
||||
|
||||
Approved response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```http
|
||||
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.
|
||||
|
||||
```http
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "idle",
|
||||
"message": "Heartbeat OK",
|
||||
"wifi_rssi": -54,
|
||||
"free_heap": 184320,
|
||||
"firmware_version": "esp32-rfid-0.2.0",
|
||||
"active_write_job_id": null
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/time
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/taps
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/write-jobs/next
|
||||
```
|
||||
|
||||
No job response:
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
Job response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/write-jobs/20/complete
|
||||
```
|
||||
|
||||
Success request:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"card_uid": "04A1B2C3D4"
|
||||
}
|
||||
```
|
||||
|
||||
Failure request:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/dashboard-login
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
Reference in New Issue
Block a user