using System.Globalization; using System.Net; using System.Net.Http; using System.IO; using System.Text; using System.Text.Json; using HikvisionAttendanceManager.App.Models; namespace HikvisionAttendanceManager.App.Services; /// Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService. public sealed class HikvisionIsapiClient { private const string FaceLibraryType = "blackFD"; private const string FaceLibraryId = "1"; public async Task TestConnectionAsync(Device device, CancellationToken cancellationToken) { if (!TryGetCredentials(device, out var username, out var password, out var credentialError)) return IsapiTestResult.Failed(credentialError); using var client = CreateTestClient(device, username, password); try { using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed)."); if (!response.IsSuccessStatusCode) return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode})."); using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); var deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name; var model = FindStringProperty(document.RootElement, "model", "deviceType", "Model"); var firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion"); return IsapiTestResult.Succeeded(deviceName, model, firmware); } catch (HttpRequestException) { return IsapiTestResult.Failed("Device unreachable. Check the IP address and network connection."); } } private static bool TryGetCredentials(Device device, out string username, out string password, out string error) { username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? ""; password = !string.IsNullOrWhiteSpace(device.ProtectedPassword) ? PasswordProtector.Unprotect(device.ProtectedPassword) : Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? ""; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { error = "Hikvision credentials are not configured for this device."; return false; } error = ""; return true; } private static HttpClient CreateTestClient(Device device, string username, string password) => new(new SocketsHttpHandler { Credentials = new NetworkCredential(username, password), PreAuthenticate = false, ConnectTimeout = TimeSpan.FromSeconds(10), PooledConnectionLifetime = TimeSpan.Zero }) { BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), Timeout = TimeSpan.FromSeconds(10) }; private static HttpClient CreateClient(Device device) { if (!TryGetCredentials(device, out var username, out var password, out var error)) throw new InvalidOperationException(error); return new HttpClient(new HttpClientHandler { Credentials = new NetworkCredential(username, password), PreAuthenticate = false, UseDefaultCredentials = false }) { BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), Timeout = TimeSpan.FromSeconds(90) }; } public async Task UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken) { 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); return response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase); } public async Task CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken) { using var client = CreateClient(device); var payload = JsonSerializer.Serialize(new { UserInfo = new { employeeNo = employee.SerialNumber, name = string.IsNullOrWhiteSpace(employee.Name) ? employee.SerialNumber : employee.Name, userType = "normal", Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" }, doorRight = "1", RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } } } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); return await ToResultAsync(response, cancellationToken); } /// Lightweight user count via UserInfo Search totalMatches (maxResults=1). public async Task TryGetUserCountAsync(Device device, CancellationToken cancellationToken) { if (!TryGetCredentials(device, out var username, out var password, out _)) return null; using var client = CreateTestClient(device, username, password); var payload = JsonSerializer.Serialize(new { UserInfoSearchCond = new { searchID = "1", searchResultPosition = 0, maxResults = 1 } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken); if (!response.IsSuccessStatusCode) return null; using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var total = FindIntProperty(document.RootElement, "totalMatches", "numOfMatches", "totalMatch"); return total >= 0 ? total : null; } public async Task CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken) { using var client = CreateClient(device); var payload = JsonSerializer.Serialize(new { UserInfo = new { employeeNo, name = string.IsNullOrWhiteSpace(name) ? employeeNo : name, userType = "normal", Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" }, doorRight = "1", RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } } } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); return await ToResultAsync(response, cancellationToken); } public async Task UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken) { if (!IsJpeg(jpeg)) return ApiResult.Failed("Face processing failed: photo is not a valid JPEG."); using var client = CreateClient(device); var boundary = "---------------" + Guid.NewGuid().ToString("N"); var metadata = $$"""{"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}"}"""; var body = BuildFaceMultipart(boundary, metadata, jpeg); using var content = new ByteArrayContent(body); content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}"); using var response = await client.PostAsync("/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", content, cancellationToken); return await ToResultAsync(response, cancellationToken); } public async Task VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken) { using var client = CreateClient(device); var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) return ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); return body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase) ? ApiResult.Succeeded() : ApiResult.Failed("Face enrollment could not be verified by this device."); } public async Task DeleteUsersAsync(Device device, IReadOnlyList employeeNumbers, CancellationToken cancellationToken) { if (employeeNumbers.Count == 0) return ApiResult.Succeeded(); using var client = CreateClient(device); const int batchSize = 30; for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize) { cancellationToken.ThrowIfCancellationRequested(); var slice = employeeNumbers.Skip(offset).Take(batchSize).Select(e => new { employeeNo = e }).ToArray(); var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = slice } }); using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode || !IsSuccessfulIsapiBody(body)) { var fallback = JsonSerializer.Serialize(new { UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = slice } }); using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallback, cancellationToken); var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken); if (!fallbackResponse.IsSuccessStatusCode || !IsSuccessfulIsapiBody(fallbackBody)) return ApiResult.Failed(ExtractReason(fallbackBody, ExtractReason(body, $"HTTP {(int)fallbackResponse.StatusCode}"))); } } return ApiResult.Succeeded(); } public async Task> GetUsersAsync(Device device, CancellationToken cancellationToken) { using var client = CreateClient(device); var users = new List(); const int pageSize = 100; for (var position = 0; ; position += pageSize) { var payload = JsonSerializer.Serialize(new { UserInfoSearchCond = new { searchID = "1", searchResultPosition = position, maxResults = pageSize } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken); if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Unable to read device users (HTTP {(int)response.StatusCode})."); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var page = FindUsers(document.RootElement); users.AddRange(page); if (page.Count < pageSize) break; } return users; } public async Task> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken) { using var client = CreateClient(device); const uint major = 0; const uint minor = 0; var searchId = "1"; var startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; var endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; var punches = new List(); var searchResultPosition = 0; var maxResults = 30; while (!cancellationToken.IsCancellationRequested) { var payload = JsonSerializer.Serialize(new { AcsEventCond = new { searchID = searchId, searchResultPosition, maxResults, major, minor, startTime, endTime } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/AcsEvent?format=json", payload, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"ACS event fetch failed (HTTP {(int)response.StatusCode})."); using var document = JsonDocument.Parse(body); var status = FindStringProperty(document.RootElement, "responseStatusStrg", "responseStatusStr", "responseStatusString"); var events = ExtractAcsEvents(document.RootElement); foreach (var ev in events) { if (TryParseAttendancePunch(ev, out var punch)) punches.Add(punch); } if (events.Count < maxResults || string.Equals(status, "END", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "NO MATCH", StringComparison.OrdinalIgnoreCase)) break; searchResultPosition += events.Count; if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10); } return punches; } public async Task DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken) { using var client = CreateClient(device); var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode})."); var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl"); if (string.IsNullOrWhiteSpace(faceUrl)) return ApiResultWithBytes.Failed("No face template URL returned by device."); var path = faceUrl; if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute)) path = absolute.PathAndQuery; using var imageResponse = await client.GetAsync(path, cancellationToken); if (!imageResponse.IsSuccessStatusCode) return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode})."); var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken); return IsJpeg(bytes) ? ApiResultWithBytes.Succeeded(bytes) : ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template."); } public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; public static bool IsUploadableFaceTemplate(byte[] bytes) => IsJpeg(bytes); private static List FindUsers(JsonElement element) { if (element.ValueKind == JsonValueKind.Object) { if (element.TryGetProperty("UserInfo", out var users) && users.ValueKind == JsonValueKind.Array) return users.EnumerateArray().Select(item => new HikvisionUser( item.TryGetProperty("employeeNo", out var employeeNo) ? employeeNo.ToString() : "", item.TryGetProperty("name", out var name) ? name.ToString() : "", item.TryGetProperty("numOfFace", out var faces) && faces.TryGetInt32(out var count) ? count : 0)) .Where(user => !string.IsNullOrWhiteSpace(user.EmployeeNo)).ToList(); foreach (var property in element.EnumerateObject()) { var found = FindUsers(property.Value); if (found.Count > 0) return found; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { var found = FindUsers(item); if (found.Count > 0) return found; } } return []; } private static List ExtractAcsEvents(JsonElement element) { var results = new List(); if (element.ValueKind == JsonValueKind.Object) { if (element.TryGetProperty("AcsEvent", out var acs) && acs.TryGetProperty("InfoList", out var infoList) && infoList.ValueKind == JsonValueKind.Array) results.AddRange(infoList.EnumerateArray()); foreach (var property in element.EnumerateObject()) results.AddRange(ExtractAcsEvents(property.Value)); } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) results.AddRange(ExtractAcsEvents(item)); } return results; } private static bool TryParseAttendancePunch(JsonElement info, out AttendancePunch punch) { punch = default!; var employeeNo = FindStringProperty(info, "employeeNoString", "employeeNo", "employeeNoStr"); if (string.IsNullOrWhiteSpace(employeeNo) || !int.TryParse(employeeNo.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) return false; if (!TryExtractEventTime(info, out var checkTime)) checkTime = DateTime.Now; var minor = info.TryGetProperty("minor", out var minorProp) && minorProp.TryGetUInt32(out var minorValue) ? (int)minorValue : 0; punch = new AttendancePunch(employeeNo.Trim(), checkTime, minor); return true; } private static bool TryExtractEventTime(JsonElement info, out DateTime dt) { foreach (var key in new[] { "time", "eventTime", "verifyTime", "statusTime", "attendanceTime" }) { if (info.TryGetProperty(key, out var value) && TryParseDateTime(value, out dt)) return true; } foreach (var property in info.EnumerateObject()) { if (property.Name.Contains("time", StringComparison.OrdinalIgnoreCase) && TryParseDateTime(property.Value, out dt)) return true; } dt = default; return false; } private static bool TryParseDateTime(JsonElement value, out DateTime dt) { if (value.ValueKind == JsonValueKind.String) { var text = value.GetString() ?? ""; if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt)) return true; if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out dt)) return true; } dt = default; return false; } private static string FindStringProperty(JsonElement element, params string[] keys) { if (element.ValueKind == JsonValueKind.Object) { foreach (var key in keys) { if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) { var text = value.GetString(); if (!string.IsNullOrWhiteSpace(text)) return text; } } foreach (var property in element.EnumerateObject()) { var found = FindStringProperty(property.Value, keys); if (!string.IsNullOrWhiteSpace(found)) return found; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { var found = FindStringProperty(item, keys); if (!string.IsNullOrWhiteSpace(found)) return found; } } return ""; } private static int FindIntProperty(JsonElement element, params string[] keys) { if (element.ValueKind == JsonValueKind.Object) { foreach (var key in keys) { if (element.TryGetProperty(key, out var value)) { if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number; if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number)) return number; } } foreach (var property in element.EnumerateObject()) { var found = FindIntProperty(property.Value, keys); if (found >= 0) return found; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { var found = FindIntProperty(item, keys); if (found >= 0) return found; } } return -1; } private static async Task SendJsonAsync(HttpClient client, HttpMethod method, string uri, string json, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, uri) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; return await client.SendAsync(request, cancellationToken); } private static async Task ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken); return response.IsSuccessStatusCode && IsSuccessfulIsapiBody(body) ? ApiResult.Succeeded() : ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); } private static bool IsSuccessfulIsapiBody(string body) => string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase); private static string ExtractReason(string body, string fallback) { foreach (var key in new[] { "subStatusCode", "statusString", "errorMsg" }) { var marker = $"\"{key}\""; var index = body.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (index >= 0) return body.Substring(index, Math.Min(160, body.Length - index)).Replace("\r", " ").Replace("\n", " "); } return fallback; } private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg) { using var stream = new MemoryStream(); void Write(string text) { var bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes); } 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"); stream.Write(metadataBytes); 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"); return stream.ToArray(); } } public sealed record IsapiTestResult(bool Success, string Reason, string? DeviceName, string? Model, string? Firmware) { public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) => new(true, "", deviceName, model, firmware); public static IsapiTestResult Failed(string reason) => new(false, reason, null, null, null); } public sealed record ApiResult(bool Success, string Reason) { public static ApiResult Succeeded() => new(true, ""); public static ApiResult Failed(string reason) => new(false, reason); }