feat(isapi): rewrite ISAPI client for connection test, delete, and face enrollment

XML/JSON connection test, delete success parsing (subStatusCode: ok), FDLib discovery, hand-built multipart face upload, and UserInfo numOfFace verification.
main
SYED MUSTUFA AHMED NAQVI 2026-08-27 09:45:14 +05:00
parent 2b3e8ea619
commit 85ac3c7b3f
1 changed files with 721 additions and 90 deletions

View File

@ -4,6 +4,8 @@ using System.Net.Http;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using HikvisionAttendanceManager.App.Models; using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services; namespace HikvisionAttendanceManager.App.Services;
@ -11,64 +13,204 @@ namespace HikvisionAttendanceManager.App.Services;
/// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary> /// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary>
public sealed class HikvisionIsapiClient public sealed class HikvisionIsapiClient
{ {
private const string FaceLibraryType = "blackFD"; private const string FaceDataRecordPath = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json";
private const string FaceLibraryId = "1"; private const string FaceLibDiscoveryPath = "/ISAPI/Intelligent/FDLib?format=json";
// Legacy template download still uses a default library when FDSearch is called elsewhere.
private const string TemplateFaceLibraryType = "blackFD";
private const string TemplateFaceLibraryId = "1";
private const int TestTimeoutSeconds = 10;
private const int ResponsePreviewLength = 320;
private const int FaceVerificationRetryDelayMs = 300;
public async Task<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken) public async Task<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken)
{ {
if (!TryGetCredentials(device, out var username, out var password, out var credentialError)) if (!TryGetCredentials(device, out var username, out var password, out var credentialError))
return IsapiTestResult.Failed(credentialError); return IsapiTestResult.CredentialsMissing(credentialError);
using var client = CreateTestClient(device, username, password); using var client = CreateTestClient(device, username, password);
try var logLines = new List<string>();
foreach (var path in new[] { "/ISAPI/System/deviceInfo", "/ISAPI/System/deviceInfo?format=json" })
{ {
using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested();
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var requestUri = new Uri(client.BaseAddress!, path).AbsoluteUri;
logLines.Add($"request={requestUri}");
if (response.StatusCode == HttpStatusCode.Unauthorized) try
return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed)."); {
using var response = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var contentType = response.Content.Headers.ContentType?.MediaType ?? "(none)";
logLines.Add($"status={(int)response.StatusCode} {response.StatusCode}");
logLines.Add($"contentType={contentType}");
logLines.Add($"bodyPreview={SafeBodyPreview(body)}");
if (!response.IsSuccessStatusCode) if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode})."); return IsapiTestResult.AuthenticationFailed("Authentication failed (invalid Hikvision credentials).", logLines);
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); if (response.StatusCode == HttpStatusCode.NotFound && path.Contains("format=json", StringComparison.Ordinal))
var deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name; continue;
var model = FindStringProperty(document.RootElement, "model", "deviceType", "Model");
var firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion"); if (!response.IsSuccessStatusCode)
return IsapiTestResult.Succeeded(deviceName, model, firmware); return IsapiTestResult.Offline($"ISAPI request failed (HTTP {(int)response.StatusCode}).", logLines);
}
catch (HttpRequestException) if (TryParseDeviceInfo(body, contentType, device, out var deviceName, out var model, out var firmware, out var parseNote))
{ {
return IsapiTestResult.Failed("Device unreachable. Check the IP address and network connection."); if (!string.IsNullOrWhiteSpace(parseNote))
return IsapiTestResult.ApiResponseError(parseNote, deviceName, model, firmware, logLines);
return IsapiTestResult.Online(deviceName, model, firmware, logLines);
}
return IsapiTestResult.ApiResponseError(
"Device responded but returned an unexpected ISAPI payload.",
device.Name,
null,
null,
logLines);
}
catch (HttpRequestException ex)
{
logLines.Add($"networkError={ex.Message}");
if (path.Contains("format=json", StringComparison.Ordinal))
return IsapiTestResult.Offline("Device unreachable. Check the IP address and network connection.", logLines);
}
catch (OperationCanceledException)
{
logLines.Add("timeout=true");
return IsapiTestResult.Timeout("Device did not respond within the connection timeout.", logLines);
}
catch (JsonException ex)
{
logLines.Add($"jsonError={ex.Message}");
return IsapiTestResult.ApiResponseError("Device returned JSON that could not be parsed.", device.Name, null, null, logLines);
}
catch (Exception ex) when (ex is System.Xml.XmlException or InvalidOperationException)
{
logLines.Add($"xmlError={ex.Message}");
return IsapiTestResult.ApiResponseError("Device returned XML that could not be parsed.", device.Name, null, null, logLines);
}
} }
return IsapiTestResult.Offline("Device unreachable. Check the IP address and network connection.", logLines);
} }
private static bool TryGetCredentials(Device device, out string username, out string password, out string error) private static bool TryParseDeviceInfo(
string body,
string contentType,
Device device,
out string? deviceName,
out string? model,
out string? firmware,
out string? parseNote)
{ {
username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? ""; deviceName = null;
password = !string.IsNullOrWhiteSpace(device.ProtectedPassword) model = null;
? PasswordProtector.Unprotect(device.ProtectedPassword) firmware = null;
: Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? ""; parseNote = null;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
if (string.IsNullOrWhiteSpace(body))
{ {
error = "Hikvision credentials are not configured for this device."; deviceName = device.Name;
return false; parseNote = "Device responded with an empty body; connectivity and authentication succeeded.";
return true;
} }
error = "";
return true; if (LooksLikeXml(body, contentType))
{
try
{
var document = XDocument.Parse(body);
deviceName = FindXmlValue(document, "deviceName", "DeviceName") ?? device.Name;
model = FindXmlValue(document, "model", "deviceType", "Model");
firmware = FindXmlValue(document, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion");
return true;
}
catch (Exception ex)
{
parseNote = $"XML parse warning: {ex.Message}";
deviceName = device.Name;
return true;
}
}
if (LooksLikeJson(body, contentType))
{
try
{
using var document = JsonDocument.Parse(body);
deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name;
model = FindStringProperty(document.RootElement, "model", "deviceType", "Model");
firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion");
return true;
}
catch (JsonException ex)
{
parseNote = $"JSON parse warning: {ex.Message}";
deviceName = device.Name;
return true;
}
}
if (body.Contains("deviceName", StringComparison.OrdinalIgnoreCase) ||
body.Contains("DeviceInfo", StringComparison.OrdinalIgnoreCase))
{
deviceName = device.Name;
parseNote = "Device returned a recognizable ISAPI payload with an unsupported format.";
return true;
}
return false;
} }
private static bool LooksLikeXml(string body, string contentType) =>
contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) ||
body.TrimStart().StartsWith('<');
private static bool LooksLikeJson(string body, string contentType) =>
contentType.Contains("json", StringComparison.OrdinalIgnoreCase) ||
body.TrimStart().StartsWith('{') ||
body.TrimStart().StartsWith('[');
private static string? FindXmlValue(XDocument document, params string[] localNames)
{
foreach (var name in localNames)
{
var value = document.Descendants()
.FirstOrDefault(element => element.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase))
?.Value?.Trim();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
private static string SafeBodyPreview(string body)
{
if (string.IsNullOrWhiteSpace(body))
return "(empty)";
var compact = body.Replace('\r', ' ').Replace('\n', ' ').Trim();
return compact.Length <= ResponsePreviewLength
? compact
: compact[..ResponsePreviewLength] + "...";
}
private static bool TryGetCredentials(Device device, out string username, out string password, out string error) =>
HikvisionCredentialsFactory.TryGetForDevice(device, out username, out password, out error);
private static HttpClient CreateTestClient(Device device, string username, string password) => private static HttpClient CreateTestClient(Device device, string username, string password) =>
new(new SocketsHttpHandler new(new SocketsHttpHandler
{ {
Credentials = new NetworkCredential(username, password), Credentials = new NetworkCredential(username, password),
PreAuthenticate = false, PreAuthenticate = false,
ConnectTimeout = TimeSpan.FromSeconds(10), ConnectTimeout = TimeSpan.FromSeconds(TestTimeoutSeconds),
PooledConnectionLifetime = TimeSpan.Zero PooledConnectionLifetime = TimeSpan.Zero
}) })
{ {
BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"),
Timeout = TimeSpan.FromSeconds(10) Timeout = TimeSpan.FromSeconds(TestTimeoutSeconds)
}; };
private static HttpClient CreateClient(Device device) private static HttpClient CreateClient(Device device)
@ -86,6 +228,7 @@ public sealed class HikvisionIsapiClient
public async Task<bool> UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken) public async Task<bool> UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(UserExistsAsync), $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Search");
using var client = CreateClient(device); using var client = CreateClient(device);
var json = JsonSerializer.Serialize(new var json = JsonSerializer.Serialize(new
{ {
@ -99,11 +242,15 @@ public sealed class HikvisionIsapiClient
}); });
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
return response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase); var exists = response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase);
AppLogger.TraceExit("ISAPI", nameof(UserExistsAsync), $"employee={employeeNo} exists={exists} http={(int)response.StatusCode}");
return exists;
} }
public async Task<ApiResult> CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken) public async Task<ApiResult> CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(CreateUserAsync),
$"device={device.IpAddress} employee={employee.SerialNumber} path=/ISAPI/AccessControl/UserInfo/Record");
using var client = CreateClient(device); using var client = CreateClient(device);
var payload = JsonSerializer.Serialize(new var payload = JsonSerializer.Serialize(new
{ {
@ -118,7 +265,10 @@ public sealed class HikvisionIsapiClient
} }
}); });
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
return await ToResultAsync(response, cancellationToken); var result = await ToResultAsync(response, cancellationToken);
AppLogger.TraceExit("ISAPI", nameof(CreateUserAsync),
$"employee={employee.SerialNumber} success={result.Success} reason={result.Reason}");
return result;
} }
/// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary> /// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary>
@ -148,6 +298,8 @@ public sealed class HikvisionIsapiClient
public async Task<ApiResult> CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken) public async Task<ApiResult> CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(CreateUserAsync),
$"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Record");
using var client = CreateClient(device); using var client = CreateClient(device);
var payload = JsonSerializer.Serialize(new var payload = JsonSerializer.Serialize(new
{ {
@ -162,64 +314,185 @@ public sealed class HikvisionIsapiClient
} }
}); });
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
return await ToResultAsync(response, cancellationToken); var result = await ToResultAsync(response, cancellationToken);
AppLogger.TraceExit("ISAPI", nameof(CreateUserAsync),
$"employee={employeeNo} success={result.Success} reason={result.Reason}");
return result;
} }
public async Task<ApiResult> UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken) public async Task<ApiResult> UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken)
{ {
if (!IsJpeg(jpeg)) return ApiResult.Failed("Face processing failed: photo is not a valid JPEG."); AppLogger.TraceEnter("ISAPI", nameof(UploadFaceAsync),
$"device={device.IpAddress} employee={employeeNo} bytes={jpeg.Length} path={FaceDataRecordPath}");
if (!IsJpeg(jpeg))
{
AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=not-jpeg");
return ApiResult.Failed("Face processing failed: photo is not a valid JPEG.");
}
if (!FaceImageNormalizer.TryNormalizeForEnrollment(jpeg, out var normalizedJpeg, out var normalizeNote))
{
AppLogger.Warning($"[ISAPI] Face normalization failed employee={employeeNo} note={normalizeNote}");
AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=normalize-failed");
return ApiResult.Failed($"Face processing failed: {normalizeNote}");
}
AppLogger.Info($"[ISAPI] Face normalized employee={employeeNo} {normalizeNote}");
jpeg = normalizedJpeg;
using var client = CreateClient(device); using var client = CreateClient(device);
var boundary = "---------------" + Guid.NewGuid().ToString("N"); var libraries = await DiscoverFaceLibrariesAsync(client, cancellationToken).ConfigureAwait(false);
var metadata = $$"""{"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}"}"""; if (libraries.Count == 0)
var body = BuildFaceMultipart(boundary, metadata, jpeg); {
using var content = new ByteArrayContent(body); AppLogger.Warning("[ISAPI] FDLib discovery returned no libraries.");
content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}"); AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=no-fdlib");
using var response = await client.PostAsync("/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", content, cancellationToken); return ApiResult.Failed("Face library discovery failed. The device did not return any FDLib entries.");
return await ToResultAsync(response, cancellationToken); }
AppLogger.Info($"[ISAPI] FDLib discovery count={libraries.Count} libraries={string.Join(", ", libraries.Select(l => $"{l.FaceLibType}:{l.Fdid}"))}");
ApiResult? lastFailure = null;
foreach (var library in OrderFaceLibraries(libraries))
{
cancellationToken.ThrowIfCancellationRequested();
AppLogger.Info($"[ISAPI] Face upload attempt employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid}");
var attempt = await TryUploadFaceToLibraryAsync(client, employeeNo, jpeg, library, cancellationToken).ConfigureAwait(false);
if (attempt.Success)
{
AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync),
$"employee={employeeNo} success=true faceLibType={library.FaceLibType} FDID={library.Fdid}");
return attempt;
}
lastFailure = attempt;
AppLogger.Warning($"[ISAPI] Face upload failed employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid} reason={attempt.Reason}");
}
AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false");
return lastFailure ?? ApiResult.Failed("Face upload failed on all discovered libraries.");
} }
public async Task<ApiResult> VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken) public async Task<ApiResult> VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken)
{ {
using var client = CreateClient(device); AppLogger.TraceEnter("ISAPI", nameof(VerifyFaceAsync),
var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Search");
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); for (var attempt = 0; attempt < 2; attempt++)
var body = await response.Content.ReadAsStringAsync(cancellationToken); {
if (!response.IsSuccessStatusCode) return ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); cancellationToken.ThrowIfCancellationRequested();
return body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase) if (attempt > 0)
? ApiResult.Succeeded() await Task.Delay(FaceVerificationRetryDelayMs, cancellationToken).ConfigureAwait(false);
: ApiResult.Failed("Face enrollment could not be verified by this device.");
var faceCount = await TryGetUserFaceCountAsync(device, employeeNo, cancellationToken).ConfigureAwait(false);
AppLogger.Info($"[ISAPI] Face verification attempt={attempt + 1} employee={employeeNo} numOfFace={faceCount?.ToString() ?? "unknown"}");
if (faceCount is >= 1)
{
AppLogger.TraceExit("ISAPI", nameof(VerifyFaceAsync), $"employee={employeeNo} success=true numOfFace={faceCount}");
return ApiResult.Succeeded();
}
}
AppLogger.TraceExit("ISAPI", nameof(VerifyFaceAsync), $"employee={employeeNo} success=false numOfFace=0");
return ApiResult.Failed("Face enrollment could not be verified (numOfFace < 1).");
} }
public async Task<ApiResult> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken) public async Task<int?> TryGetUserFaceCountAsync(Device device, string employeeNo, CancellationToken cancellationToken)
{ {
if (employeeNumbers.Count == 0) return ApiResult.Succeeded();
using var client = CreateClient(device); using var client = CreateClient(device);
var json = JsonSerializer.Serialize(new
{
UserInfoSearchCond = new
{
searchID = "1",
searchResultPosition = 0,
maxResults = 10,
EmployeeNoList = new[] { new { employeeNo } }
}
});
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
using var document = JsonDocument.Parse(body);
var users = FindUsers(document.RootElement);
var match = users.FirstOrDefault(user => string.Equals(user.EmployeeNo, employeeNo, StringComparison.OrdinalIgnoreCase));
return match?.FaceCount;
}
public async Task<IReadOnlyList<UserDeleteResult>> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
{
AppLogger.TraceEnter("ISAPI", nameof(DeleteUsersAsync),
$"device={device.IpAddress} count={employeeNumbers.Count} employees={string.Join(",", employeeNumbers)}");
if (employeeNumbers.Count == 0) return [];
using var client = CreateClient(device);
var results = new List<UserDeleteResult>();
const int batchSize = 30; const int batchSize = 30;
for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize) for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var slice = employeeNumbers.Skip(offset).Take(batchSize).Select(e => new { employeeNo = e }).ToArray(); var slice = employeeNumbers.Skip(offset).Take(batchSize).ToList();
var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = slice } }); var batchResult = await TryDeleteEmployeeBatchAsync(client, slice, cancellationToken).ConfigureAwait(false);
using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken); if (batchResult.Success)
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode || !IsSuccessfulIsapiBody(body))
{ {
var fallback = JsonSerializer.Serialize(new results.AddRange(slice.Select(employeeNo => new UserDeleteResult(employeeNo, ApiResult.Succeeded())));
{ continue;
UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = slice } }
});
using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put, foreach (var employeeNo in slice)
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallback, cancellationToken); {
var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken); var singleResult = await TryDeleteEmployeeBatchAsync(client, [employeeNo], cancellationToken).ConfigureAwait(false);
if (!fallbackResponse.IsSuccessStatusCode || !IsSuccessfulIsapiBody(fallbackBody)) results.Add(new UserDeleteResult(employeeNo, singleResult));
return ApiResult.Failed(ExtractReason(fallbackBody, ExtractReason(body, $"HTTP {(int)fallbackResponse.StatusCode}")));
} }
} }
return ApiResult.Succeeded();
AppLogger.TraceExit("ISAPI", nameof(DeleteUsersAsync),
$"success={results.Count(r => r.Result.Success)} failed={results.Count(r => !r.Result.Success)}");
return results;
}
private static async Task<ApiResult> TryDeleteEmployeeBatchAsync(HttpClient client, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
{
var employeeLabel = employeeNumbers.Count == 1 ? employeeNumbers[0] : $"batch[{employeeNumbers.Count}]";
AppLogger.TraceEnter("ISAPI", nameof(TryDeleteEmployeeBatchAsync),
$"employees={employeeLabel} path=/ISAPI/AccessControl/UserInfo/Delete");
var list = employeeNumbers.Select(e => new { employeeNo = e }).ToArray();
var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = list } });
using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var httpStatus = (int)response.StatusCode;
if (IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _))
{
AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync),
$"employees={employeeLabel} success=true attempt=primary http={httpStatus}");
return ApiResult.Succeeded();
}
AppLogger.Info($"[ISAPI] TryDeleteEmployeeBatchAsync primary failed employees={employeeLabel} http={httpStatus}; trying fallback");
var fallbackPayload = JsonSerializer.Serialize(new
{
UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = list }
});
using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put,
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallbackPayload, cancellationToken);
var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var fallbackHttpStatus = (int)fallbackResponse.StatusCode;
if (IsConfirmedIsapiSuccess(fallbackBody, fallbackHttpStatus, out _, out _, out _))
{
AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync),
$"employees={employeeLabel} success=true attempt=fallback http={fallbackHttpStatus}");
return ApiResult.Succeeded();
}
var failure = ApiResult.Failed(DescribeIsapiFailure(body, httpStatus, fallbackBody, fallbackHttpStatus));
AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync),
$"employees={employeeLabel} success=false reason={failure.Reason}");
return failure;
} }
public async Task<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken) public async Task<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(GetUsersAsync),
$"device={device.IpAddress} path=/ISAPI/AccessControl/UserInfo/Search");
using var client = CreateClient(device); using var client = CreateClient(device);
var users = new List<HikvisionUser>(); var users = new List<HikvisionUser>();
const int pageSize = 100; const int pageSize = 100;
@ -233,11 +506,14 @@ public sealed class HikvisionIsapiClient
users.AddRange(page); users.AddRange(page);
if (page.Count < pageSize) break; if (page.Count < pageSize) break;
} }
AppLogger.TraceExit("ISAPI", nameof(GetUsersAsync), $"count={users.Count}");
return users; return users;
} }
public async Task<IReadOnlyList<AttendancePunch>> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken) public async Task<IReadOnlyList<AttendancePunch>> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(FetchAcsEventsAsync),
$"device={device.IpAddress} from={fromLocal:yyyy-MM-dd HH:mm} to={toLocal:yyyy-MM-dd HH:mm} path=/ISAPI/AccessControl/AcsEvent");
using var client = CreateClient(device); using var client = CreateClient(device);
const uint major = 0; const uint major = 0;
const uint minor = 0; const uint minor = 0;
@ -284,21 +560,32 @@ public sealed class HikvisionIsapiClient
if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10); if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10);
} }
AppLogger.TraceExit("ISAPI", nameof(FetchAcsEventsAsync), $"count={punches.Count}");
return punches; return punches;
} }
public async Task<ApiResultWithBytes> DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken) public async Task<ApiResultWithBytes> DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken)
{ {
AppLogger.TraceEnter("ISAPI", nameof(DownloadFaceTemplateAsync),
$"device={device.IpAddress} employee={employeeNo} path=/ISAPI/Intelligent/FDLib/FDSearch");
using var client = CreateClient(device); using var client = CreateClient(device);
var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{TemplateFaceLibraryType}}","FDID":"{{TemplateFaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}""";
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{
AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync),
$"employee={employeeNo} success=false http={(int)response.StatusCode}");
return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode})."); return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode}).");
}
var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl"); var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl");
if (string.IsNullOrWhiteSpace(faceUrl)) if (string.IsNullOrWhiteSpace(faceUrl))
{
AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync),
$"employee={employeeNo} success=false reason=no-face-url");
return ApiResultWithBytes.Failed("No face template URL returned by device."); return ApiResultWithBytes.Failed("No face template URL returned by device.");
}
var path = faceUrl; var path = faceUrl;
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute)) if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute))
@ -306,12 +593,19 @@ public sealed class HikvisionIsapiClient
using var imageResponse = await client.GetAsync(path, cancellationToken); using var imageResponse = await client.GetAsync(path, cancellationToken);
if (!imageResponse.IsSuccessStatusCode) if (!imageResponse.IsSuccessStatusCode)
{
AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync),
$"employee={employeeNo} success=false imageHttp={(int)imageResponse.StatusCode}");
return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode})."); return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode}).");
}
var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken); var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken);
return IsJpeg(bytes) var downloadResult = IsJpeg(bytes)
? ApiResultWithBytes.Succeeded(bytes) ? ApiResultWithBytes.Succeeded(bytes)
: ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template."); : ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template.");
AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync),
$"employee={employeeNo} success={downloadResult.Success} bytes={bytes.Length}");
return downloadResult;
} }
public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
@ -481,45 +775,378 @@ public sealed class HikvisionIsapiClient
private static async Task<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken) private static async Task<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{ {
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
return response.IsSuccessStatusCode && IsSuccessfulIsapiBody(body) var httpStatus = (int)response.StatusCode;
? ApiResult.Succeeded() if (response.IsSuccessStatusCode &&
: ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); (string.IsNullOrWhiteSpace(body) || IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _)))
return ApiResult.Succeeded();
return ApiResult.Failed(DescribeIsapiFailure(body, httpStatus));
} }
private static bool IsSuccessfulIsapiBody(string body) => /// <summary>HTTP 2xx + Hikvision statusCode 1 + statusString OK or subStatusCode ok.</summary>
string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase); private static bool IsConfirmedIsapiSuccess(string? body, int httpStatus, out int statusCode, out string statusString, out string subStatusCode)
{
statusCode = 0;
statusString = "";
subStatusCode = "";
if (httpStatus is < 200 or >= 300)
return false;
if (!TryParseIsapiResponseFields(body, out statusCode, out statusString, out subStatusCode))
return false;
if (string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase))
return true;
if (statusCode != 1)
return false;
return string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase);
}
private static bool TryParseIsapiResponseFields(string? body, out int statusCode, out string statusString, out string subStatusCode)
{
statusCode = 0;
statusString = "";
subStatusCode = "";
if (string.IsNullOrWhiteSpace(body))
return false;
if (body.TrimStart().StartsWith('{') || body.TrimStart().StartsWith('['))
{
try
{
using var document = JsonDocument.Parse(body);
var parsedCode = FindIntProperty(document.RootElement, "statusCode", "responseStatusCode");
if (parsedCode >= 0)
statusCode = parsedCode;
statusString = FindStringProperty(document.RootElement, "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString");
subStatusCode = FindStringProperty(document.RootElement, "subStatusCode");
}
catch (JsonException)
{
// fall through to regex
}
}
if (statusCode == 0)
{
var match = Regex.Match(body, "\"statusCode\"\\s*:\\s*\"?(?<v>-?\\d+)\"?", RegexOptions.IgnoreCase);
if (match.Success)
int.TryParse(match.Groups["v"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out statusCode);
}
if (string.IsNullOrEmpty(statusString))
{
var match = Regex.Match(body, "\"statusString\"\\s*:\\s*\"(?<v>[^\"]*)\"", RegexOptions.IgnoreCase);
if (match.Success)
statusString = match.Groups["v"].Value;
}
if (string.IsNullOrEmpty(subStatusCode))
{
var match = Regex.Match(body, "\"subStatusCode\"\\s*:\\s*\"(?<v>[^\"]*)\"", RegexOptions.IgnoreCase);
if (match.Success)
subStatusCode = match.Groups["v"].Value;
}
return statusCode != 0
|| !string.IsNullOrWhiteSpace(statusString)
|| !string.IsNullOrWhiteSpace(subStatusCode);
}
private static string DescribeIsapiFailure(string? body, int httpStatus, string? fallbackBody = null, int? fallbackHttpStatus = null)
{
if (httpStatus is < 200 or >= 300)
return $"HTTP {httpStatus}";
if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode))
return DescribeParsedIsapiFailure(httpStatus, statusCode, statusString, subStatusCode);
if (fallbackBody is not null && fallbackHttpStatus is int fallbackStatus)
{
if (fallbackStatus is < 200 or >= 300)
return $"HTTP {fallbackStatus}";
if (TryParseIsapiResponseFields(fallbackBody, out statusCode, out statusString, out subStatusCode))
return DescribeParsedIsapiFailure(fallbackStatus, statusCode, statusString, subStatusCode);
}
return string.IsNullOrWhiteSpace(body) ? "Device rejected the ISAPI request." : TrimReason(body);
}
private static string DescribeParsedIsapiFailure(int httpStatus, int statusCode, string statusString, string subStatusCode)
{
if (httpStatus is < 200 or >= 300)
return $"HTTP {httpStatus}";
var sub = subStatusCode.Trim();
if (sub.Length > 0)
{
if (sub.Contains("notExist", StringComparison.OrdinalIgnoreCase) ||
sub.Contains("notFound", StringComparison.OrdinalIgnoreCase) ||
sub.Equals("invalidEmployeeNo", StringComparison.OrdinalIgnoreCase) ||
sub.Equals("employeeNoNotExist", StringComparison.OrdinalIgnoreCase))
return "Employee was not found on the device";
if (!string.Equals(sub, "ok", StringComparison.OrdinalIgnoreCase))
return sub;
}
if (!string.IsNullOrWhiteSpace(statusString) &&
!string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase))
return statusString.Trim();
if (statusCode is not (0 or 1))
return $"Hikvision statusCode={statusCode}";
return "Device rejected the ISAPI request.";
}
private static string ExtractReason(string body, string fallback) private static string ExtractReason(string body, string fallback)
{ {
foreach (var key in new[] { "subStatusCode", "statusString", "errorMsg" }) if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode))
{ {
var marker = $"\"{key}\""; var reason = DescribeParsedIsapiFailure(200, statusCode, statusString, subStatusCode);
var index = body.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (!string.Equals(reason, "Device rejected the ISAPI request.", StringComparison.Ordinal))
if (index >= 0) return body.Substring(index, Math.Min(160, body.Length - index)).Replace("\r", " ").Replace("\n", " "); return reason;
} }
return fallback; return fallback;
} }
private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg) private static string TrimReason(string body)
{
var compact = body.Replace('\r', ' ').Replace('\n', ' ').Trim();
return compact.Length <= 160 ? compact : compact[..160] + "...";
}
private static async Task<IReadOnlyList<FaceLibraryInfo>> DiscoverFaceLibrariesAsync(HttpClient client, CancellationToken cancellationToken)
{
AppLogger.TraceEnter("ISAPI", nameof(DiscoverFaceLibrariesAsync), $"path={FaceLibDiscoveryPath}");
using var response = await client.GetAsync(FaceLibDiscoveryPath, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var contentType = response.Content.Headers.ContentType?.MediaType ?? "(none)";
AppLogger.Info($"[ISAPI] FDLib discovery http={(int)response.StatusCode} contentType={contentType} bodyPreview={SafeBodyPreview(body)}");
if (!response.IsSuccessStatusCode)
{
AppLogger.TraceExit("ISAPI", nameof(DiscoverFaceLibrariesAsync), "success=false");
return [];
}
var libraries = ParseFaceLibraryCandidates(body);
AppLogger.TraceExit("ISAPI", nameof(DiscoverFaceLibrariesAsync), $"success=true count={libraries.Count}");
return libraries;
}
private static IReadOnlyList<FaceLibraryInfo> OrderFaceLibraries(IReadOnlyList<FaceLibraryInfo> libraries) =>
libraries
.OrderBy(library => library.FaceLibType.Equals("blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(library => library.FaceLibType, StringComparer.OrdinalIgnoreCase)
.ThenBy(library => library.Fdid, StringComparer.OrdinalIgnoreCase)
.ToList();
private static async Task<ApiResult> TryUploadFaceToLibraryAsync(
HttpClient client,
string employeeNo,
byte[] jpeg,
FaceLibraryInfo library,
CancellationToken cancellationToken)
{
var metadata = JsonSerializer.Serialize(new
{
faceLibType = library.FaceLibType,
FDID = library.Fdid,
FPID = employeeNo
});
var boundary = "---------------" + DateTime.Now.Ticks.ToString("x", CultureInfo.InvariantCulture);
var bodyBytes = BuildHikvisionFaceMultipartBody(boundary, metadata, jpeg);
using var content = new ByteArrayContent(bodyBytes);
content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}");
using var request = new HttpRequestMessage(HttpMethod.Post, FaceDataRecordPath) { Content = content };
request.Headers.TryAddWithoutValidation("Accept", "application/json, text/html, application/xhtml+xml");
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var httpStatus = (int)response.StatusCode;
var responseContentType = response.Content.Headers.ContentType?.MediaType ?? "(none)";
TryParseIsapiResponseFields(responseBody, out var statusCode, out var statusString, out var subStatusCode);
AppLogger.Info(
$"[ISAPI] FaceDataRecord employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid} " +
$"http={httpStatus} contentType={responseContentType} statusCode={statusCode} statusString={statusString} subStatusCode={subStatusCode} " +
$"bodyPreview={SafeBodyPreview(responseBody)}");
if (!response.IsSuccessStatusCode)
return ApiResult.Failed(DescribeFaceUploadFailure(httpStatus, responseBody));
if (!IsLikelyIsapiSuccess(responseBody, httpStatus))
return ApiResult.Failed(DescribeFaceUploadFailure(httpStatus, responseBody));
return ApiResult.Succeeded();
}
private static List<FaceLibraryInfo> ParseFaceLibraryCandidates(string body)
{
var candidates = new List<FaceLibraryInfo>();
if (string.IsNullOrWhiteSpace(body))
return candidates;
try
{
using var document = JsonDocument.Parse(body);
CollectFaceLibraries(document.RootElement, candidates);
}
catch (JsonException)
{
// fall through to regex below
}
if (candidates.Count == 0)
{
foreach (Match match in Regex.Matches(body, "\"faceLibType\"\\s*:\\s*\"(?<type>[^\"]+)\".*?\"FDID\"\\s*:\\s*\"?(?<fdid>[^\",}]+)\"?", RegexOptions.IgnoreCase | RegexOptions.Singleline))
{
var faceLibType = match.Groups["type"].Value.Trim();
var fdid = match.Groups["fdid"].Value.Trim();
if (!string.IsNullOrWhiteSpace(faceLibType) && !string.IsNullOrWhiteSpace(fdid))
AddFaceLibraryCandidate(candidates, faceLibType, fdid);
}
}
return candidates;
}
private static void CollectFaceLibraries(JsonElement element, List<FaceLibraryInfo> candidates)
{
if (element.ValueKind == JsonValueKind.Object)
{
var fdid = FindStringProperty(element, "FDID", "fdId", "fdid");
var faceLibType = FindStringProperty(element, "faceLibType", "faceLib", "libType");
if (!string.IsNullOrWhiteSpace(fdid) && !string.IsNullOrWhiteSpace(faceLibType))
AddFaceLibraryCandidate(candidates, faceLibType, fdid);
foreach (var property in element.EnumerateObject())
CollectFaceLibraries(property.Value, candidates);
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
CollectFaceLibraries(item, candidates);
}
}
private static void AddFaceLibraryCandidate(List<FaceLibraryInfo> candidates, string faceLibType, string fdid)
{
var normalizedType = faceLibType.Trim();
var normalizedFdid = fdid.Trim();
if (candidates.Any(candidate =>
candidate.FaceLibType.Equals(normalizedType, StringComparison.OrdinalIgnoreCase) &&
candidate.Fdid.Equals(normalizedFdid, StringComparison.OrdinalIgnoreCase)))
return;
candidates.Add(new FaceLibraryInfo(normalizedType, normalizedFdid));
}
private static bool IsLikelyIsapiSuccess(string? body, int httpStatus)
{
if (httpStatus is < 200 or >= 300)
return false;
if (string.IsNullOrWhiteSpace(body))
return true;
if (!TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode))
return true;
if (statusCode == 1)
return true;
return string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase)
|| string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase);
}
private static string DescribeFaceUploadFailure(int httpStatus, string? body)
{
if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode))
{
if (!string.IsNullOrWhiteSpace(statusString) &&
!string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase))
{
return !string.IsNullOrWhiteSpace(subStatusCode)
? $"{statusString.Trim()} ({subStatusCode.Trim()})"
: statusString.Trim();
}
if (!string.IsNullOrWhiteSpace(subStatusCode) &&
!string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase))
return subStatusCode.Trim();
if (statusCode is not (0 or 1))
return $"Hikvision statusCode={statusCode}";
}
if (httpStatus is < 200 or >= 300)
return $"HTTP {httpStatus}";
return "Face upload rejected by device.";
}
/// <summary>Hand-built multipart body matching Hikvision FaceDataRecord requirements.</summary>
private static byte[] BuildHikvisionFaceMultipartBody(string boundary, string faceDataRecordJson, byte[] jpegBytes)
{ {
using var stream = new MemoryStream(); using var stream = new MemoryStream();
void Write(string text) { var bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes); } void WriteAscii(string text)
var metadataBytes = Encoding.UTF8.GetBytes(metadata); {
Write($"--{boundary}\r\nContent-Disposition: form-data; name=\"FaceDataRecord\";\r\nContent-Type: application/json\r\nContent-Length: {metadataBytes.Length}\r\n\r\n"); var bytes = Encoding.ASCII.GetBytes(text);
stream.Write(metadataBytes); stream.Write(bytes, 0, bytes.Length);
Write($"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"FaceImage\";\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n"); }
stream.Write(jpeg);
Write($"\r\n--{boundary}--\r\n"); var jsonBytes = Encoding.UTF8.GetBytes(faceDataRecordJson ?? "");
WriteAscii("--" + boundary + "\r\n");
WriteAscii("Content-Disposition: form-data; name=\"FaceDataRecord\";\r\n");
WriteAscii("Content-Type: application/json\r\n");
WriteAscii("Content-Length: " + jsonBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
stream.Write(jsonBytes, 0, jsonBytes.Length);
WriteAscii("\r\n--" + boundary + "\r\n");
WriteAscii("Content-Disposition: form-data; name=\"FaceImage\";\r\n");
WriteAscii("Content-Type: image/jpeg\r\n");
WriteAscii("Content-Length: " + jpegBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
stream.Write(jpegBytes, 0, jpegBytes.Length);
WriteAscii("\r\n--" + boundary + "--\r\n");
return stream.ToArray(); return stream.ToArray();
} }
private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg) =>
BuildHikvisionFaceMultipartBody(boundary, metadata, jpeg);
} }
public sealed record IsapiTestResult(bool Success, string Reason, string? DeviceName, string? Model, string? Firmware) public sealed record IsapiTestResult(
ConnectionTestOutcome Outcome,
string Reason,
string? DeviceName,
string? Model,
string? Firmware,
IReadOnlyList<string> LogLines)
{ {
public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) => public static IsapiTestResult Online(string? deviceName, string? model, string? firmware, IReadOnlyList<string> logLines) =>
new(true, "", deviceName, model, firmware); new(ConnectionTestOutcome.Online, "", deviceName, model, firmware, logLines);
public static IsapiTestResult Failed(string reason) => new(false, reason, null, null, null); public static IsapiTestResult ApiResponseError(string reason, string? deviceName, string? model, string? firmware, IReadOnlyList<string> logLines) =>
new(ConnectionTestOutcome.ApiResponseError, reason, deviceName, model, firmware, logLines);
public static IsapiTestResult AuthenticationFailed(string reason, IReadOnlyList<string> logLines) =>
new(ConnectionTestOutcome.AuthenticationFailed, reason, null, null, null, logLines);
public static IsapiTestResult Timeout(string reason, IReadOnlyList<string> logLines) =>
new(ConnectionTestOutcome.Timeout, reason, null, null, null, logLines);
public static IsapiTestResult Offline(string reason, IReadOnlyList<string> logLines) =>
new(ConnectionTestOutcome.Offline, reason, null, null, null, logLines);
public static IsapiTestResult CredentialsMissing(string reason) =>
new(ConnectionTestOutcome.CredentialsMissing, reason, null, null, null, Array.Empty<string>());
} }
public sealed record ApiResult(bool Success, string Reason) public sealed record ApiResult(bool Success, string Reason)
@ -527,3 +1154,7 @@ public sealed record ApiResult(bool Success, string Reason)
public static ApiResult Succeeded() => new(true, ""); public static ApiResult Succeeded() => new(true, "");
public static ApiResult Failed(string reason) => new(false, reason); public static ApiResult Failed(string reason) => new(false, reason);
} }
public sealed record UserDeleteResult(string EmployeeNumber, ApiResult Result);
public sealed record FaceLibraryInfo(string FaceLibType, string Fdid);