# Agent contract — `UpsertFacePhoto` **Status: implemented on both sides.** The agent side lives in `Access-device-agent` (`Devices/HikvisionIsapiClient.SetUserFaceAsync` and the `UpsertFacePhoto` case in `Services/CommandWorker.DispatchAsync`). This document remains the reference for the wire format and the device call. Not yet verified against a physical terminal — see "Untested" at the end. ## Flow ``` Web ──POST client/customers/{id}/face-photo──▶ Server │ validates JPEG, queues DeviceCommand ▼ Agent ──GET api/agent/commands ─────────────────▶ receives UpsertFacePhoto ──ISAPI POST────────────────────▶ Hikvision terminal (gym LAN) ──POST api/agent/commands/{id}/result ────▶ Server │ purges payload, updates profile ▼ SignalR "FacePhotoUpdated" ──▶ browser ``` ## Command the agent receives `GET api/agent/commands` returns the usual envelope; `CommandType` is `UpsertFacePhoto`. `PayloadJson`: ```json { "bindingId": "8f1c...", // guid, AccessDeviceBinding "accessProfileId": 6, // long "employeeNo": "HF6", // the person id already used by UpsertPerson "name": "Prabodha", "faceJpegBase64": "/9j/4AAQ...", // bare base64, no data: prefix "desiredVersion": 4 } ``` The image is already normalised by the browser and re-checked server-side: | Property | Guaranteed | |---|---| | Format | JPEG (magic bytes verified) | | Dimensions | 480×640 from the web client; ≤ 1920×1920 enforced | | Size | ≤ 200 KB enforced, typically 60–150 KB | So the agent does **not** need to resize or convert. Decode the base64 and POST it. **Ordering:** the person record must exist on the terminal before a face can attach to it. `employeeNo` is the same value `UpsertPerson` uses. If a face command arrives for a person the terminal doesn't have, run the person upsert first rather than failing. ## ISAPI call Face data is attached to an existing person in a face library. Typical shape for the DS-K1T series (confirm against your firmware — this varies more than Hikvision's docs suggest): ``` POST /ISAPI/Intelligent/FDLib/FDSetUp?format=json Content-Type: multipart/form-data; boundary=---boundary Authorization: Digest ... ---boundary Content-Disposition: form-data; name="FaceDataRecord" Content-Type: application/json { "faceLibType": "blackFD", "FDID": "1", "FPID": "HF6", "name": "Prabodha" } ---boundary Content-Disposition: form-data; name="FaceImage"; filename="face.jpg" Content-Type: image/jpeg ---boundary-- ``` - `FPID` should be the `employeeNo`, so the face ties to the same person record. - Use **Digest** auth, as with the other endpoints. - Replacing a photo is the same call — the device overwrites the FPID's face. ## Result the agent posts back `POST api/agent/commands/{id}/result` **Success:** ```json { "success": true, "resultJson": "{\"fpid\":\"HF6\"}" } ``` The server reads `fpid` (also accepts `FPID`, `faceId`, `faceURL`) into `AccessProfile.FacePhotoFPID` — the handle needed to replace or delete the face later. Anything longer than 64 chars is truncated. **Failure:** ```json { "success": false, "resultJson": "{\"detail\":\"No frontal face detected in the image\"}" } ``` Put a human-readable reason in `detail` — it is surfaced to gym staff verbatim, so it should say what to do about it. Real Hikvision rejections worth mapping: | Device response | Suggested `detail` | |---|---| | `lowFaceQuality` / no face found | `No clear frontal face was detected. Retake with the face centred and well lit.` | | face already bound to another FPID | `This face is already enrolled for another member.` | | library full | `The terminal's face library is full.` | | image format rejected | `The terminal rejected the image format.` | ## Retry behaviour `MaxAttempts` is **3** for face commands (lower than the 5 used for person/state commands): a face rejected for quality will be rejected identically on every retry, so failing fast gets the real message in front of staff sooner. - Retries use the existing exponential backoff. - On success **or** final failure the server clears `PayloadJson`, so the image is not retained. A retry between those points still has the payload available. - A newer photo supersedes an older queued one — the stale command is marked `Superseded` and its payload cleared. The agent should treat `Superseded` as "stop, discard". ## Deleting a face The **agent** side is implemented (`DeleteUserFaceAsync`, plus a `DeleteFacePhoto` dispatch case), calling: ``` PUT /ISAPI/Intelligent/FDLib/FDSearch/Delete?format=json&FDID=1&faceLibType=blackFD { "FPID": [ { "value": "HF6" } ] } ``` The **server** does not queue this command yet — nothing calls it when a member is removed. Adding that is a small change: queue `DeleteFacePhoto` with the stored `FacePhotoFPID` wherever a member is deactivated, then clear the face columns on success. ## Untested Everything here compiles and the wire format matches on both sides, but **no part of this has run against a physical Hikvision terminal.** Expect to verify: - `FDID = "1"` and `faceLibType = "blackFD"` are the right library on your model. Terminals with multiple face libraries may need a different FDID. - The multipart part names (`FaceDataRecord`, `FaceImage`) — these vary between firmware generations more than Hikvision's documentation suggests. - Whether your model accepts 480×640 at ~150 KB, or wants smaller. The agent logs the full device response for both success and failure (`SetUserFace {EmployeeNo} ... : {Response}`), which is the fastest way to see what the terminal actually objects to on the first real run.