commit 2455f712961df69faadfd2c90a9c8a4f910e9610 Author: mustafa.ahmed Date: Mon Apr 6 17:19:57 2026 +0500 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..610ba64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Build folders +bin/ +obj/ +Debug/ +Release/ +x64/ +x86/ +AnyCPU/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Rider / JetBrains +.idea/ +*.iml + +# NuGet +packages/ +*.nupkg +.nuget/ + +# Logs +*.log +logs/ +SdkLog/ +attendance_logs_*.txt +face_templates_log.txt +fingerprint_templates_log.txt +attendance_records.txt + +# Local config / secrets +appsettings.Development.json +appsettings.Local.json +serviceconfig.Local.json +*.secret +*.env +serviceconfig.json +appsettings.json + +# Publish output +publish/ +out/ + +# Test / temp files +*.tmp +*.temp +*.bak +*.old + +# OS files +.DS_Store +Thumbs.db + +# If SDK DLLs are local/generated and should not be committed +# Uncomment only if you do NOT want them in repo +# HCNetSDK/ +# *.dll + +# Keep sample configs if needed +# !serviceconfig.json +# !appsettings.json \ No newline at end of file diff --git a/AcsAttendanceParser.cs b/AcsAttendanceParser.cs new file mode 100644 index 0000000..a8b74a0 --- /dev/null +++ b/AcsAttendanceParser.cs @@ -0,0 +1,175 @@ +using System; +using CHCNetSDK = EventByDeploy.CHCNetSDK; + +namespace HikvisionAttendanceService; + +/// +/// ACS-specific parsing helpers (major/minor codes from HCNetSDK). +/// +internal static class AcsAttendanceParser +{ + public static string MapMajorCategory(uint dwMajor) + { + if (dwMajor == CHCNetSDK.MAJOR_ALARM) return "Alarm"; + if (dwMajor == CHCNetSDK.MAJOR_EXCEPTION) return "Exception"; + if (dwMajor == CHCNetSDK.MAJOR_OPERATION) return "Operation"; + if (dwMajor == CHCNetSDK.MAJOR_EVENT) return "Event"; + return "Unknown"; + } + + public static bool TryInferSuccessFromMinor(uint dwMajor, uint dwMinor, out bool isSuccess) + { + isSuccess = false; + if (dwMajor != CHCNetSDK.MAJOR_EVENT) + return false; + + // Verification / access style minors: *_PASS / *_FAIL / *_TIMEOUT patterns in SDK. + if (IsPassMinor(dwMinor)) + { + isSuccess = true; + return true; + } + + if (IsFailOrTimeoutMinor(dwMinor)) + { + isSuccess = false; + return true; + } + + return false; + } + + public static bool ResolveIsSuccess(uint dwMajor, uint dwMinor, string? eventName) + { + if (TryInferSuccessFromMinor(dwMajor, dwMinor, out var ok)) + return ok; + return GuessSuccessFromEventName(eventName); + } + + private static bool GuessSuccessFromEventName(string? eventName) + { + if (eventName == null) + return false; + + if (eventName.IndexOf("FAIL", StringComparison.OrdinalIgnoreCase) >= 0 || + eventName.IndexOf("TIMEOUT", StringComparison.OrdinalIgnoreCase) >= 0 || + eventName.IndexOf("INEXISTENCE", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + if (eventName.IndexOf("PASS", StringComparison.OrdinalIgnoreCase) >= 0 || + eventName.IndexOf("SUCCESS", StringComparison.OrdinalIgnoreCase) >= 0 || + eventName.IndexOf("OPEN", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + return false; + } + + private static bool IsPassMinor(uint m) + { + return m == CHCNetSDK.MINOR_LEGAL_CARD_PASS + || m == CHCNetSDK.MINOR_FACE_VERIFY_PASS + || m == CHCNetSDK.MINOR_FINGERPRINT_COMPARE_PASS + || m == CHCNetSDK.MINOR_CARD_AND_PSW_PASS + || m == CHCNetSDK.MINOR_CARD_FINGERPRINT_VERIFY_PASS + || m == CHCNetSDK.MINOR_CARD_FINGERPRINT_PASSWD_VERIFY_PASS + || m == CHCNetSDK.MINOR_FINGERPRINT_PASSWD_VERIFY_PASS + || m == CHCNetSDK.MINOR_FACE_AND_FP_VERIFY_PASS + || m == CHCNetSDK.MINOR_FACE_AND_PW_VERIFY_PASS + || m == CHCNetSDK.MINOR_FACE_AND_CARD_VERIFY_PASS + || m == CHCNetSDK.MINOR_FACE_AND_PW_AND_FP_VERIFY_PASS + || m == CHCNetSDK.MINOR_FACE_CARD_AND_FP_VERIFY_PASS + || m == CHCNetSDK.MINOR_EMPLOYEENO_AND_FP_VERIFY_PASS + || m == CHCNetSDK.MINOR_EMPLOYEENO_AND_FP_AND_PW_VERIFY_PASS + || m == CHCNetSDK.MINOR_EMPLOYEENO_AND_FACE_VERIFY_PASS + || m == CHCNetSDK.MINOR_COMBINED_VERIFY_PASS; + } + + private static bool IsFailOrTimeoutMinor(uint m) + { + return m == CHCNetSDK.MINOR_FINGERPRINT_COMPARE_FAIL + || m == CHCNetSDK.MINOR_CARD_FINGERPRINT_VERIFY_FAIL + || m == CHCNetSDK.MINOR_CARD_FINGERPRINT_VERIFY_TIMEOUT + || m == CHCNetSDK.MINOR_FACE_AND_FP_VERIFY_FAIL + || m == CHCNetSDK.MINOR_FACE_AND_FP_VERIFY_TIMEOUT + || m == CHCNetSDK.MINOR_FACE_VERIFY_FAIL + || m == CHCNetSDK.MINOR_FACE_AND_PW_VERIFY_FAIL + || m == CHCNetSDK.MINOR_FACE_AND_CARD_VERIFY_FAIL + || m == CHCNetSDK.MINOR_FINGERPRINT_INEXISTENCE; + } + + /// + /// Maps SDK verify mode / card reader kind to a coarse attendance method. + /// + public static string InferMethodFromAcsDetail( + uint dwMinor, + byte byCardReaderKind, + byte byCurrentVerifyMode, + string eventNameFallback) + { + // Prefer explicit minor-driven inference for MAJOR_EVENT. + string fromMinor = MapMethodFromMinor(dwMinor); + if (!string.IsNullOrEmpty(fromMinor)) + return fromMinor; + + // byCardReaderKind: 1 IC, 2 ID, 3 QR, 4 fingerprint head (per SDK comments). + switch (byCardReaderKind) + { + case 4: + return "Fingerprint"; + case 1: + case 2: + case 3: + return "Card"; + } + + // Common verify modes (device-dependent; 0 = unknown). + switch (byCurrentVerifyMode) + { + case 2: + return "Fingerprint"; + case 3: + case 4: + return "Face"; + case 1: + return "Card"; + } + + return GuessMethodFromEventName(eventNameFallback); + } + + private static string MapMethodFromMinor(uint m) + { + if (m == CHCNetSDK.MINOR_FACE_VERIFY_PASS || m == CHCNetSDK.MINOR_FACE_VERIFY_FAIL) + return "Face"; + if (m == CHCNetSDK.MINOR_FINGERPRINT_COMPARE_PASS || m == CHCNetSDK.MINOR_FINGERPRINT_COMPARE_FAIL) + return "Fingerprint"; + if (m == CHCNetSDK.MINOR_CARD_AND_PSW_PASS) + return "Card+Password"; + if (m == CHCNetSDK.MINOR_FACE_AND_FP_VERIFY_PASS || m == CHCNetSDK.MINOR_FACE_AND_FP_VERIFY_FAIL) + return "Face+Fingerprint"; + if (m == CHCNetSDK.MINOR_FACE_AND_CARD_VERIFY_PASS || m == CHCNetSDK.MINOR_FACE_AND_CARD_VERIFY_FAIL) + return "Face+Card"; + return ""; + } + + private static string GuessMethodFromEventName(string? eventName) + { + if (string.IsNullOrEmpty(eventName)) + return "Unknown"; + + string n = eventName.ToUpperInvariant(); + if (n.Contains("FACE") && n.Contains("FP")) + return "Face+Fingerprint"; + if (n.Contains("FACE")) + return "Face"; + if (n.Contains("FINGERPRINT") || n.Contains("FINGE_RPRINT") || n.Contains("FINGER_PRINT") || n.Contains(" FP")) + return "Fingerprint"; + if (n.Contains("CARD")) + return "Card"; + return "Unknown"; + } +} diff --git a/AllUsersTemplateExportPayload.cs b/AllUsersTemplateExportPayload.cs new file mode 100644 index 0000000..a7800bf --- /dev/null +++ b/AllUsersTemplateExportPayload.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace HikvisionAttendanceService; + +internal sealed class AllUsersTemplateExportPayload +{ + public string exportedAtUtc { get; set; } = ""; + public string deviceId { get; set; } = ""; + public string userListSource { get; set; } = ""; + public string userListCardKey { get; set; } = "employeeNo"; + public string listFetchError { get; set; } = ""; + public List users { get; set; } = new List(); +} + diff --git a/AttendanceTestMode.cs b/AttendanceTestMode.cs new file mode 100644 index 0000000..eb92fdf --- /dev/null +++ b/AttendanceTestMode.cs @@ -0,0 +1,445 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace HikvisionAttendanceService; + +/// +/// Console test harness: run the same SDK + pipeline as the Windows Service without installing the service. +/// Usage: HikvisionAttendanceService.exe --test +/// HikvisionAttendanceService.exe --test --fetch "DS-K1T642MFW-7378360" --from "2026-03-01 08:00:00" --to "2026-03-28 23:59:59" +/// HikvisionAttendanceService.exe --test --export-templates "DS-K1T642MFW-FM7378360" --card "1001" [--out "C:\SdkLog\templates.json"] +/// HikvisionAttendanceService.exe --test --export-all-templates-isapi "DS-K1T642MFW-FM7378360" [--outDir "C:\SdkLog\templates_isapi"] [--pageSize 30] [--maxUsers 5000] +/// +internal static class AttendanceTestMode +{ + private const string BuildMarker = "CFGDIAG_20260331_1"; + + public static async Task RunAsync(string[] args) + { + Environment.CurrentDirectory = AppContext.BaseDirectory; + + var baseDir = AppContext.BaseDirectory; + var serviceCfgPath = Path.Combine(baseDir, "serviceconfig.json"); + var appSettingsPath = Path.Combine(baseDir, "appsettings.json"); + var chosenConfigPath = File.Exists(serviceCfgPath) ? serviceCfgPath : appSettingsPath; + + // Startup diagnostics: prove which config file is used and what devices it contains. + // (Logger needs LogDirectory, so we bootstrap a temporary logger first.) + var bootstrapLogger = new HikvisionAttendanceWindowsService.FileLogger(@"C:\SdkLog"); + bootstrapLogger.Info("TEST config: baseDir=\"" + baseDir + "\""); + bootstrapLogger.Info("TEST config: candidate serviceconfig.json=\"" + serviceCfgPath + "\" exists=" + File.Exists(serviceCfgPath)); + bootstrapLogger.Info("TEST config: candidate appsettings.json=\"" + appSettingsPath + "\" exists=" + File.Exists(appSettingsPath)); + bootstrapLogger.Info("TEST config: chosenConfigPath=\"" + chosenConfigPath + "\" exists=" + File.Exists(chosenConfigPath)); + + var config = HikvisionAttendanceWindowsService.HikvisionServiceConfig.Load(chosenConfigPath); + var logger = new HikvisionAttendanceWindowsService.FileLogger(config.LogDirectory); + logger.Info("TEST config: loadedFromPath=\"" + chosenConfigPath + "\" exists=" + File.Exists(chosenConfigPath)); + logger.Info("TEST config: devicesLoadedCount=" + (config.Devices == null ? "(null)" : config.Devices.Count.ToString())); + if (config.Devices == null || config.Devices.Count == 0) + { + logger.Warn("TEST config: Devices is null/empty; StartDevices will skip all logins."); + } + else + { + foreach (var d in config.Devices) + { + logger.Info("TEST config: device entry: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port + + " Username=\"" + (d.Username ?? "") + "\""); + } + } + + var rawJoined = args.Length == 0 ? "(none)" : string.Join(" ", args.Select(QuoteForLog)); + logger.Info("TEST mode entry: build=" + BuildMarker + + " baseDir=\"" + baseDir + "\"" + + " chosenConfigPath=\"" + chosenConfigPath + "\" chosenExists=" + File.Exists(chosenConfigPath) + + " rawArgCount=" + args.Length + " rawArgs=" + rawJoined); + + var fetchWanted = args.Any(a => string.Equals(a, "--fetch", StringComparison.OrdinalIgnoreCase)); + var exportAllWanted = args.Any(a => string.Equals(a, "--export-all-templates", StringComparison.OrdinalIgnoreCase)); + var exportAllIsapiWanted = args.Any(a => string.Equals(a, "--export-all-templates-isapi", StringComparison.OrdinalIgnoreCase)); + var exportTemplatesWanted = args.Any(a => string.Equals(a, "--export-templates", StringComparison.OrdinalIgnoreCase)); + var exportParseOk = TryParseExportTemplateArgs(args, out var exportDeviceId, out var exportCardNo, out var exportOutPath, out var exportParseFailure); + var exportAllParseOk = TryParseExportAllTemplatesArgs(args, out var exportAllDeviceId, out var exportAllOutDir, out var exportAllPageSize, out var exportAllMaxUsers, out var exportAllParseFailure); + var exportAllIsapiParseOk = TryParseExportAllIsapiTemplatesArgs(args, out var exportAllIsapiDeviceId, out var exportAllIsapiOutDir, out var exportAllIsapiPageSize, out var exportAllIsapiMaxUsers, out var exportAllIsapiParseFailure); + var parsedOk = TryParseFetchArgs(args, out var deviceId, out var from, out var to, out var parseFailure); + logger.Info("TEST mode parse: exportTemplatesFlagPresent=" + exportTemplatesWanted + ", exportBranchWillRun=" + exportParseOk + + ", exportDeviceId=" + (string.IsNullOrEmpty(exportDeviceId) ? "(empty)" : exportDeviceId) + + ", exportCardNo=" + (string.IsNullOrEmpty(exportCardNo) ? "(empty)" : exportCardNo) + + ", exportOutPath=" + (string.IsNullOrEmpty(exportOutPath) ? "(default under LogDirectory)" : exportOutPath) + + (exportParseOk ? "" : ", exportParseFailed=" + exportParseFailure)); + logger.Info("TEST mode parse: exportAllFlagPresent=" + exportAllWanted + ", exportAllBranchWillRun=" + exportAllParseOk + + ", exportAllDeviceId=" + (string.IsNullOrEmpty(exportAllDeviceId) ? "(empty)" : exportAllDeviceId) + + ", exportAllOutDir=" + (string.IsNullOrEmpty(exportAllOutDir) ? "(default under LogDirectory)" : exportAllOutDir) + + ", pageSize=" + exportAllPageSize + + ", maxUsers=" + exportAllMaxUsers + + (exportAllParseOk ? "" : ", exportAllParseFailed=" + exportAllParseFailure)); + logger.Info("TEST mode parse: exportAllIsapiFlagPresent=" + exportAllIsapiWanted + ", exportAllIsapiBranchWillRun=" + exportAllIsapiParseOk + + ", exportAllIsapiDeviceId=" + (string.IsNullOrEmpty(exportAllIsapiDeviceId) ? "(empty)" : exportAllIsapiDeviceId) + + ", exportAllIsapiOutDir=" + (string.IsNullOrEmpty(exportAllIsapiOutDir) ? "(default under LogDirectory)" : exportAllIsapiOutDir) + + ", pageSize=" + exportAllIsapiPageSize + + ", maxUsers=" + exportAllIsapiMaxUsers + + (exportAllIsapiParseOk ? "" : ", exportAllIsapiParseFailed=" + exportAllIsapiParseFailure)); + logger.Info("TEST mode parse: fetchFlagPresent=" + fetchWanted + ", fetchBranchWillRun=" + parsedOk + + ", parsedDeviceId=" + (string.IsNullOrEmpty(deviceId) ? "(empty)" : deviceId) + + ", parsedFrom=" + (from == default ? "(unset)" : from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)) + + ", parsedTo=" + (to == default ? "(unset)" : to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)) + + (parsedOk ? "" : ", fetchParseFailed=" + parseFailure)); + + Console.WriteLine("HikvisionAttendanceService TEST mode"); + Console.WriteLine("Log directory: " + config.LogDirectory); + Console.WriteLine("Press Ctrl+C to exit."); + if (exportTemplatesWanted && !exportParseOk) + Console.WriteLine("Template export not started: " + exportParseFailure); + if (exportAllWanted && !exportAllParseOk) + Console.WriteLine("All template export not started: " + exportAllParseFailure); + if (exportAllIsapiWanted && !exportAllIsapiParseOk) + Console.WriteLine("All ISAPI template export not started: " + exportAllIsapiParseFailure); + if (!parsedOk && fetchWanted) + Console.WriteLine("Fetch not started: " + parseFailure); + + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + + using var manager = new HikvisionAttendanceManager(config, logger); + var run = manager.RunAsync(cts.Token); + + await Task.Delay(3500, CancellationToken.None).ConfigureAwait(false); + + if (exportAllIsapiParseOk) + { + logger.Info("TEST mode: entering export-all templates ISAPI branch deviceId=" + exportAllIsapiDeviceId + + ", outDir=" + (string.IsNullOrEmpty(exportAllIsapiOutDir) ? "(default LogDirectory)" : exportAllIsapiOutDir) + + ", pageSize=" + exportAllIsapiPageSize + + ", maxUsers=" + exportAllIsapiMaxUsers); + + Console.WriteLine($"Exporting ALL face + fingerprint templates via ISAPI: device={exportAllIsapiDeviceId}, pageSize={exportAllIsapiPageSize}, maxUsers={exportAllIsapiMaxUsers}"); + + string jsonPath = ""; + string exportErr = ""; + bool exportOk = await Task.Run(() => + manager.TryExportAllUsersTemplatesToIsapiFile( + exportAllIsapiDeviceId, + string.IsNullOrEmpty(exportAllIsapiOutDir) ? null : exportAllIsapiOutDir, + exportAllIsapiPageSize, + exportAllIsapiMaxUsers, + cts.Token, + out jsonPath, + out exportErr), cts.Token).ConfigureAwait(false); + + if (exportOk) + { + logger.Info("TEST mode: export-all (ISAPI) templates finished OK jsonPath=" + jsonPath); + Console.WriteLine("ISAPI export-all templates written: " + jsonPath); + } + else + { + logger.Warn("TEST mode: export-all (ISAPI) templates failed: " + exportErr); + Console.WriteLine("ISAPI export-all templates failed: " + exportErr); + } + + Console.WriteLine("Stopping test host..."); + cts.Cancel(); + } + else if (exportAllParseOk) + { + logger.Info("TEST mode: entering export-all templates branch deviceId=" + exportAllDeviceId + + ", outDir=" + (string.IsNullOrEmpty(exportAllOutDir) ? "(default LogDirectory)" : exportAllOutDir) + + ", pageSize=" + exportAllPageSize + + ", maxUsers=" + exportAllMaxUsers); + + Console.WriteLine($"Exporting ALL face + fingerprint templates: device={exportAllDeviceId}, pageSize={exportAllPageSize}, maxUsers={exportAllMaxUsers}"); + + string jsonPath = ""; + string exportErr = ""; + bool exportOk = await Task.Run(() => + manager.TryExportAllUsersTemplatesToFile( + exportAllDeviceId, + string.IsNullOrEmpty(exportAllOutDir) ? null : exportAllOutDir, + exportAllPageSize, + exportAllMaxUsers, + cts.Token, + out jsonPath, + out exportErr), cts.Token).ConfigureAwait(false); + + if (exportOk) + { + logger.Info("TEST mode: export-all templates finished OK jsonPath=" + jsonPath); + Console.WriteLine("All templates export written: " + jsonPath); + } + else + { + logger.Warn("TEST mode: export-all templates failed: " + exportErr); + Console.WriteLine("Export-all templates failed: " + exportErr); + } + + Console.WriteLine("Stopping test host..."); + cts.Cancel(); + } + else if (exportParseOk) + { + logger.Info("TEST mode: entering template export branch deviceId=" + exportDeviceId + " cardNo=" + exportCardNo + + (string.IsNullOrEmpty(exportOutPath) ? "" : " out=" + exportOutPath)); + Console.WriteLine($"Exporting face + fingerprint templates: device={exportDeviceId}, card={exportCardNo}"); + string writtenPath = ""; + string exportErr = ""; + bool exportOk = await Task.Run(() => + manager.TryExportUserTemplatesToFile(exportDeviceId, exportCardNo, + string.IsNullOrEmpty(exportOutPath) ? null : exportOutPath, out writtenPath, out exportErr), cts.Token).ConfigureAwait(false); + if (exportOk) + { + logger.Info("TEST mode: template export finished OK path=" + writtenPath); + Console.WriteLine("Template export written: " + writtenPath); + } + else + { + logger.Warn("TEST mode: template export failed: " + exportErr); + Console.WriteLine("Template export failed: " + exportErr); + } + + Console.WriteLine("Stopping test host..."); + cts.Cancel(); + } + else if (parsedOk) + { + logger.Info("TEST mode: entering CLI fetch branch for deviceId=" + deviceId + + " from=" + from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " to=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + Console.WriteLine($"Fetching historical ACS events: device={deviceId}, from={from}, to={to}"); + int n = await manager.FetchAttendanceRecordsAsync(deviceId, from, to, cts.Token).ConfigureAwait(false); + logger.Info("TEST mode: CLI fetch finished, recordsParsedOk=" + n); + Console.WriteLine("Fetch completed, records enqueued: " + n); + Console.WriteLine("Stopping test host..."); + cts.Cancel(); + } + else + { + logger.Info("TEST mode: live-only branch (no CLI fetch). " + (fetchWanted ? parseFailure : "Reason: " + parseFailure)); + Console.WriteLine("Live ACS + scheduled historical fetch (if enabled) running. Tail today's attendance log for details."); + Console.WriteLine("Optional: --test --fetch DeviceId --from yyyy-MM-dd HH:mm:ss --to yyyy-MM-dd HH:mm:ss"); + Console.WriteLine("Optional: --test --export-templates DeviceId --card CardOrEmployeeNo [--out path.json]"); + Console.WriteLine("Optional: --test --export-all-templates DeviceId [--outDir path] [--pageSize 30] [--maxUsers 5000]"); + Console.WriteLine("Optional: --test --export-all-templates-isapi DeviceId [--outDir path] [--pageSize 30] [--maxUsers 5000]"); + Console.WriteLine("Waiting... (Ctrl+C to stop)"); + } + + try + { + await run.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected + } + } + + private static string QuoteForLog(string a) + { + if (string.IsNullOrEmpty(a)) + return a; + return a.Any(char.IsWhiteSpace) ? "\"" + a.Replace("\"", "\\\"") + "\"" : a; + } + + private static bool TryParseExportTemplateArgs(string[] args, out string deviceId, out string cardNo, out string outPath, out string failureReason) + { + deviceId = ""; + cardNo = ""; + outPath = ""; + failureReason = ""; + + if (!args.Any(a => string.Equals(a, "--export-templates", StringComparison.OrdinalIgnoreCase))) + { + failureReason = "no --export-templates in args"; + return false; + } + + deviceId = (GetArgAfterSwitch(args, "--export-templates") ?? "").Trim().Trim('"'); + cardNo = (GetArgAfterSwitch(args, "--card") ?? "").Trim().Trim('"'); + var outVal = GetArgAfterSwitch(args, "--out"); + outPath = string.IsNullOrWhiteSpace(outVal) ? "" : outVal.Trim().Trim('"'); + + if (string.IsNullOrWhiteSpace(deviceId)) + { + failureReason = "missing value after --export-templates"; + return false; + } + + if (string.IsNullOrWhiteSpace(cardNo)) + { + failureReason = "missing --card (SDK enroll key on device)"; + return false; + } + + return true; + } + + private static bool TryParseExportAllTemplatesArgs( + string[] args, + out string deviceId, + out string outDir, + out int pageSize, + out int maxUsers, + out string failureReason) + { + deviceId = ""; + outDir = ""; + pageSize = 30; + maxUsers = 10000; + failureReason = ""; + + if (!args.Any(a => string.Equals(a, "--export-all-templates", StringComparison.OrdinalIgnoreCase))) + { + failureReason = "no --export-all-templates in args"; + return false; + } + + deviceId = (GetArgAfterSwitch(args, "--export-all-templates") ?? "").Trim().Trim('"'); + var outVal = GetArgAfterSwitch(args, "--outDir"); + outDir = string.IsNullOrWhiteSpace(outVal) ? "" : outVal.Trim().Trim('"'); + + var pageVal = GetArgAfterSwitch(args, "--pageSize"); + if (!string.IsNullOrWhiteSpace(pageVal) && int.TryParse(pageVal, out var ps)) + pageSize = Math.Max(1, ps); + + var maxVal = GetArgAfterSwitch(args, "--maxUsers"); + if (!string.IsNullOrWhiteSpace(maxVal) && int.TryParse(maxVal, out var mu)) + maxUsers = Math.Max(1, mu); + + if (string.IsNullOrWhiteSpace(deviceId)) + { + failureReason = "missing value after --export-all-templates"; + return false; + } + + return true; + } + + private static bool TryParseExportAllIsapiTemplatesArgs( + string[] args, + out string deviceId, + out string outDir, + out int pageSize, + out int maxUsers, + out string failureReason) + { + deviceId = ""; + outDir = ""; + pageSize = 30; + maxUsers = 10000; + failureReason = ""; + + if (!args.Any(a => string.Equals(a, "--export-all-templates-isapi", StringComparison.OrdinalIgnoreCase))) + { + failureReason = "no --export-all-templates-isapi in args"; + return false; + } + + deviceId = (GetArgAfterSwitch(args, "--export-all-templates-isapi") ?? "").Trim().Trim('\"'); + var outVal = GetArgAfterSwitch(args, "--outDir"); + outDir = string.IsNullOrWhiteSpace(outVal) ? "" : outVal.Trim().Trim('\"'); + + var pageVal = GetArgAfterSwitch(args, "--pageSize"); + if (!string.IsNullOrWhiteSpace(pageVal) && int.TryParse(pageVal, out var ps)) + pageSize = Math.Max(1, ps); + + var maxVal = GetArgAfterSwitch(args, "--maxUsers"); + if (!string.IsNullOrWhiteSpace(maxVal) && int.TryParse(maxVal, out var mu)) + maxUsers = Math.Max(1, mu); + + if (string.IsNullOrWhiteSpace(deviceId)) + { + failureReason = "missing value after --export-all-templates-isapi"; + return false; + } + + return true; + } + + private static bool TryParseFetchArgs(string[] args, out string deviceId, out DateTime from, out DateTime to, out string failureReason) + { + deviceId = ""; + from = default; + to = default; + failureReason = ""; + + bool wantFetch = false; + for (int i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--fetch", StringComparison.OrdinalIgnoreCase)) + wantFetch = true; + } + + if (!wantFetch) + { + failureReason = "no --fetch in args"; + return false; + } + + var fetchVal = GetArgAfterSwitch(args, "--fetch"); + var fromVal = GetArgAfterSwitch(args, "--from"); + var toVal = GetArgAfterSwitch(args, "--to"); + + deviceId = (fetchVal ?? "").Trim().Trim('"'); + bool fromOk = TryParseCliDateTime(fromVal, out from); + bool toOk = TryParseCliDateTime(toVal, out to); + + if (string.IsNullOrWhiteSpace(deviceId)) + { + failureReason = "missing value after --fetch (need --fetch )"; + return false; + } + + if (!fromOk) + { + failureReason = "invalid or missing --from (got \"" + (fromVal ?? "(missing)") + "\"; use yyyy-MM-dd HH:mm:ss or yyyy-MM-dd)"; + return false; + } + + if (!toOk) + { + failureReason = "invalid or missing --to (got \"" + (toVal ?? "(missing)") + "\"; use yyyy-MM-dd HH:mm:ss or yyyy-MM-dd)"; + return false; + } + + return true; + } + + private static string? GetArgAfterSwitch(string[] args, string switchName) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], switchName, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + + return null; + } + + private static bool TryParseCliDateTime(string? raw, out DateTime dt) + { + dt = default; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + var s = raw.Trim().Trim('"'); + string[] formats = + { + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-dd" + }; + + if (DateTime.TryParseExact(s, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt)) + return true; + + return DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt); + } +} diff --git a/DeviceIdentity.cs b/DeviceIdentity.cs new file mode 100644 index 0000000..73ac7a4 --- /dev/null +++ b/DeviceIdentity.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; + +namespace HikvisionAttendanceService; + +/// +/// Single place for DeviceId trimming and session/fetch lookup keys so config, CLI, and logs stay aligned. +/// +internal static class DeviceIdentity +{ + private static readonly Regex FMNumericSuffix = new Regex(@"-FM(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// Value stored from JSON after load — trim only; never replace user’s chosen id. + public static string NormalizeConfigured(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return ""; + return raw.Trim(); + } + + /// + /// Stable key for FindSession / fetch: same physical device if only the "-FM" serial prefix differs + /// (e.g. DS-K1T642MFW-FM7378360 vs DS-K1T642MFW-7378360). + /// + public static string CanonicalLookupKey(string? raw) + { + var s = NormalizeConfigured(raw); + if (s.Length == 0) + return ""; + + s = FMNumericSuffix.Replace(s, "-$1"); + return s.ToUpperInvariant(); + } +} diff --git a/HCNetSDK/AudioRender.dll b/HCNetSDK/AudioRender.dll new file mode 100644 index 0000000..d0de8f6 Binary files /dev/null and b/HCNetSDK/AudioRender.dll differ diff --git a/HCNetSDK/EagleEyeRender.dll b/HCNetSDK/EagleEyeRender.dll new file mode 100644 index 0000000..2451b24 Binary files /dev/null and b/HCNetSDK/EagleEyeRender.dll differ diff --git a/HCNetSDK/HCCore.dll b/HCNetSDK/HCCore.dll new file mode 100644 index 0000000..cbf66a6 Binary files /dev/null and b/HCNetSDK/HCCore.dll differ diff --git a/HCNetSDK/HCNetSDK.dll b/HCNetSDK/HCNetSDK.dll new file mode 100644 index 0000000..e88a77f Binary files /dev/null and b/HCNetSDK/HCNetSDK.dll differ diff --git a/HCNetSDK/HXVA.dll b/HCNetSDK/HXVA.dll new file mode 100644 index 0000000..b397cd0 Binary files /dev/null and b/HCNetSDK/HXVA.dll differ diff --git a/HCNetSDK/HmMerge.dll b/HCNetSDK/HmMerge.dll new file mode 100644 index 0000000..29b9c72 Binary files /dev/null and b/HCNetSDK/HmMerge.dll differ diff --git a/HCNetSDK/MP_Render.dll b/HCNetSDK/MP_Render.dll new file mode 100644 index 0000000..9bb3d85 Binary files /dev/null and b/HCNetSDK/MP_Render.dll differ diff --git a/HCNetSDK/MP_VIE.dll b/HCNetSDK/MP_VIE.dll new file mode 100644 index 0000000..078f289 Binary files /dev/null and b/HCNetSDK/MP_VIE.dll differ diff --git a/HCNetSDK/NPQos.dll b/HCNetSDK/NPQos.dll new file mode 100644 index 0000000..80f8fb6 Binary files /dev/null and b/HCNetSDK/NPQos.dll differ diff --git a/HCNetSDK/OpenAL32.dll b/HCNetSDK/OpenAL32.dll new file mode 100644 index 0000000..1a6f17f Binary files /dev/null and b/HCNetSDK/OpenAL32.dll differ diff --git a/HCNetSDK/PlayCtrl.dll b/HCNetSDK/PlayCtrl.dll new file mode 100644 index 0000000..d1464e1 Binary files /dev/null and b/HCNetSDK/PlayCtrl.dll differ diff --git a/HCNetSDK/SuperRender.dll b/HCNetSDK/SuperRender.dll new file mode 100644 index 0000000..6f2865f Binary files /dev/null and b/HCNetSDK/SuperRender.dll differ diff --git a/HCNetSDK/hlog.dll b/HCNetSDK/hlog.dll new file mode 100644 index 0000000..2c10fe6 Binary files /dev/null and b/HCNetSDK/hlog.dll differ diff --git a/HCNetSDK/hpr.dll b/HCNetSDK/hpr.dll new file mode 100644 index 0000000..9256289 Binary files /dev/null and b/HCNetSDK/hpr.dll differ diff --git a/HCNetSDK/libcrypto-1_1.dll b/HCNetSDK/libcrypto-1_1.dll new file mode 100644 index 0000000..04bb024 Binary files /dev/null and b/HCNetSDK/libcrypto-1_1.dll differ diff --git a/HCNetSDK/libssl-1_1.dll b/HCNetSDK/libssl-1_1.dll new file mode 100644 index 0000000..4a79420 Binary files /dev/null and b/HCNetSDK/libssl-1_1.dll differ diff --git a/HCNetSDK/zlib1.dll b/HCNetSDK/zlib1.dll new file mode 100644 index 0000000..af8e80c Binary files /dev/null and b/HCNetSDK/zlib1.dll differ diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs new file mode 100644 index 0000000..a3643e9 --- /dev/null +++ b/HikvisionAttendanceManager.cs @@ -0,0 +1,4460 @@ +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Data.SqlClient; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using EventByDeploy; +using Common; +using HikvisionAttendanceService.Interop; +using CHCNetSDK = EventByDeploy.CHCNetSDK; + +namespace HikvisionAttendanceService; + +internal sealed class HikvisionAttendanceManager : IDisposable +{ + private const string BuildMarker = "CFGDIAG_20260331_1"; + private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config; + private readonly HikvisionAttendanceWindowsService.FileLogger _logger; + + private readonly ConcurrentQueue _queue = new ConcurrentQueue(); + private readonly SemaphoreSlim _queueSignal = new SemaphoreSlim(0, int.MaxValue); + private readonly int _queueMax = 1024; + private int _queueSize; + + private readonly object _csvWriteLock = new object(); + private readonly object _attendanceTextLock = new object(); + private readonly string _csvPath; + private readonly string _exportPath; + private readonly string _hrExportPath; + private readonly string _attendanceTextPath; + + private readonly List _sessions = new List(); + + private CancellationTokenSource _cts; + private Task _queueWriterTask; + private Task _exportTask; + private Common.CHCNetSDK.MSGCallBack _callbackDelegate; + + private readonly ConcurrentDictionary _dedupeKeys = new ConcurrentDictionary(); + private const int DedupeMaxEntries = 50_000; + + public HikvisionAttendanceManager( + HikvisionAttendanceWindowsService.HikvisionServiceConfig config, + HikvisionAttendanceWindowsService.FileLogger logger) + { + _config = config; + _logger = logger; + + Directory.CreateDirectory(_config.LogDirectory); + _csvPath = Path.Combine(_config.LogDirectory, "attendance_events.csv"); + _exportPath = Path.Combine(_config.LogDirectory, "attendance_export.csv"); + _hrExportPath = string.IsNullOrWhiteSpace(_config.HrExportPath) + ? Path.Combine(_config.LogDirectory, "attendance_hr_sync.csv") + : _config.HrExportPath; + + _attendanceTextPath = string.IsNullOrWhiteSpace(_config.AttendanceTextFilePath) + ? Path.Combine(_config.LogDirectory, "attendance_records.txt") + : _config.AttendanceTextFilePath.Trim(); + + var textDir = Path.GetDirectoryName(Path.GetFullPath(_attendanceTextPath)); + if (!string.IsNullOrEmpty(textDir)) + Directory.CreateDirectory(textDir); + + EnsureCsvSchema(); + } + + private void EnsureCsvSchema() + { + const string header = + "DeviceId,DeviceIp,Timestamp,EmployeeNo,UserIdentifier,CardNo,DoorNo,ReaderNo,Method,EventName,EventType,Source,IsSuccess,RawMajor,RawMinor"; + + if (!File.Exists(_csvPath)) + { + File.WriteAllText(_csvPath, header + Environment.NewLine); + return; + } + + try + { + var first = File.ReadLines(_csvPath).FirstOrDefault() ?? ""; + if (first.IndexOf("ReaderNo", StringComparison.OrdinalIgnoreCase) < 0 || + first.IndexOf("Source", StringComparison.OrdinalIgnoreCase) < 0) + { + var bak = _csvPath + ".legacy_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".bak"; + File.Copy(_csvPath, bak, overwrite: true); + File.WriteAllText(_csvPath, header + Environment.NewLine); + _logger.Warn("attendance CSV schema upgraded; previous file copied to " + bak); + } + } + catch (Exception ex) + { + _logger.Error("EnsureCsvSchema failed", ex); + } + } + + /// Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch. + public async Task RunAsync(CancellationToken token) + { + Environment.CurrentDirectory = AppContext.BaseDirectory; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + _queueWriterTask = Task.Run(() => QueueWriterLoop(_cts.Token), _cts.Token); + _exportTask = Task.Run(() => ExportLoop(_cts.Token), _cts.Token); + + try + { + if (!Common.CHCNetSDK.NET_DVR_Init()) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + _logger.Error("NET_DVR_Init failed, error=" + err); + return; + } + + _logger.Info("NET_DVR_Init succeeded."); + + _logger.Info("HCNetSDK routing: all native entry points that must share init state use Common.CHCNetSDK (DllImport HCNetSDK\\HCNetSDK.dll next to exe). " + + "EventByDeploy types are used only for ACS struct layouts and TypeMap (no separate DllImport calls for callback/alarm/remote-config)."); + + _logger.Info("Attendance persistence: plain-text file=\"" + _attendanceTextPath + "\", database INSERTs " + + (_config.EnableDatabasePersistence && !string.IsNullOrWhiteSpace(_config.SqlConnectionString) + ? "ENABLED" + : "DISABLED (set EnableDatabasePersistence true + SqlConnectionString to re-enable)")); + + StartDevices(); + + _logger.Info("Startup: active device sessions=" + _sessions.Count + ". Historical ACS query API: NET_DVR_GET_ACS_EVENT is available."); + + if (_config.HistoricalFetchIntervalMinutes > 0 && _sessions.Count > 0) + { + _logger.Info("Scheduled historical fetch enabled: every " + _config.HistoricalFetchIntervalMinutes + + " min, lookback " + _config.HistoricalFetchLookbackMinutes + " min."); + _ = Task.Run(() => HistoricalSchedulerLoop(_cts.Token), _cts.Token); + } + + await Task.Delay(Timeout.Infinite, _cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // shutdown + } + catch (Exception ex) + { + _logger.Error("RunAsync main loop crashed", ex); + } + finally + { + StopDevices(); + try { Common.CHCNetSDK.NET_DVR_Cleanup(); } catch { /* ignore */ } + _logger.Info("NET_DVR_Cleanup completed."); + } + } + + /// Fetches stored ACS events from the device for the given local time range and enqueues them into the same pipeline. + public Task FetchAttendanceRecordsAsync(string deviceId, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + DateTime? lastEventTimestamp; + int n = FetchAttendanceRecordsCore(deviceId, fromLocal, toLocal, cancellationToken, out lastEventTimestamp); + + // CLI/manual fetches should also advance the last-sync cursor, + // otherwise incremental sync won't work until the scheduled loop runs. + string filePath = GetLastSyncFilePath(deviceId); + _logger.Info("LastSync write check (CLI/manual fetch): device=" + deviceId + ", n=" + n + + ", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)") + + ", filePath=" + filePath); + + if (n > 0 && lastEventTimestamp.HasValue) + { + WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value); + _logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId + + ", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + return n; + }, cancellationToken); + } + + public bool OpenDoor(string deviceId, out string error) + { + return ControlDoor(deviceId, 1, out error); + } + + public bool CloseDoor(string deviceId, out string error) + { + return ControlDoor(deviceId, 0, out error); + } + + public bool StayOpen(string deviceId, out string error) + { + return ControlDoor(deviceId, 2, out error); + } + + public bool StayClose(string deviceId, out string error) + { + return ControlDoor(deviceId, 3, out error); + } + + public bool SetFaceTemplate(string deviceId, string cardNo, int readerNo, byte[] faceImageBytes, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + if (string.IsNullOrWhiteSpace(cardNo)) + { + error = "cardNo is required"; + return false; + } + if (faceImageBytes == null || faceImageBytes.Length == 0) + { + error = "faceImageBytes is empty"; + return false; + } + if (faceImageBytes.Length > 200 * 1024) + { + error = "face image exceeds 200KB"; + return false; + } + + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + if (readerNo <= 0) + readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1; + + int handle = -1; + IntPtr condPtr = IntPtr.Zero; + IntPtr inPtr = IntPtr.Zero; + IntPtr outPtr = IntPtr.Zero; + IntPtr facePtr = IntPtr.Zero; + + try + { + var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_COND(); + cond.Init(); + cond.dwSize = (uint)Marshal.SizeOf(cond); + cond.dwFaceNum = 1; + cond.dwEnableReaderNo = (uint)readerNo; + CopyUtf8(cardNo, cond.byCardNo); + + condPtr = Marshal.AllocHGlobal((int)cond.dwSize); + Marshal.StructureToPtr(cond, condPtr, false); + + handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig( + session.UserId, + (uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FACE, + condPtr, + (int)cond.dwSize, + null, + IntPtr.Zero); + + if (handle < 0) + { + var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError(); + error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FACE) failed, err=" + sdkErr; + _logger.Error(error); + return false; + } + + var record = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_RECORD(); + record.Init(); + record.dwSize = (uint)Marshal.SizeOf(record); + CopyUtf8(cardNo, record.byCardNo); + record.dwFaceLen = (uint)faceImageBytes.Length; + facePtr = Marshal.AllocHGlobal(faceImageBytes.Length); + Marshal.Copy(faceImageBytes, 0, facePtr, faceImageBytes.Length); + record.pFaceBuffer = facePtr; + + var status = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_STATUS(); + status.Init(); + status.dwSize = (uint)Marshal.SizeOf(status); + + inPtr = Marshal.AllocHGlobal((int)record.dwSize); + outPtr = Marshal.AllocHGlobal((int)status.dwSize); + uint outLen = 0; + int attempts = 0; + bool accepted = false; + + while (attempts++ < 300) + { + Marshal.StructureToPtr(record, inPtr, false); + Marshal.StructureToPtr(status, outPtr, false); + + int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig( + handle, + inPtr, + (uint)record.dwSize, + outPtr, + (uint)status.dwSize, + ref outLen); + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT) + { + Thread.Sleep(50); + continue; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH) + { + if (accepted) + return true; + error = "face config finished before success status"; + return false; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS) + { + status = Marshal.PtrToStructure(outPtr); + if (status.byRecvStatus == 1) + { + accepted = true; + continue; + } + + string msg = DecodeCardNo(status.byErrorMsg); + error = "face template rejected, recvStatus=" + status.byRecvStatus + ", readerNo=" + status.dwReaderNo + ", msg=" + msg; + _logger.Warn(error); + return false; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED || + rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION) + { + var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError(); + error = "face template remote config failed, rc=" + rc + ", err=" + sdkErr; + _logger.Error(error); + return false; + } + } + + error = "face template timed out waiting for device response"; + return false; + } + catch (Exception ex) + { + error = "SetFaceTemplate exception: " + ex.Message; + _logger.Error(error, ex); + return false; + } + finally + { + try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ } + try { if (facePtr != IntPtr.Zero) Marshal.FreeHGlobal(facePtr); } catch { /* ignore */ } + try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ } + try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ } + try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ } + } + } + + public bool GetFaceTemplate(string deviceId, string cardNo, out byte[] faceImageBytes, out string error) + { + error = ""; + faceImageBytes = Array.Empty(); + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1; + bool ok = HikvisionTemplateInterop.GetFaceParam(session.UserId, channel, cardNo, out faceImageBytes); + if (!ok) + { + error = "GetFaceTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FACE_PARAM_CFG)"); + _logger.Error(error); + } + return ok; + } + + public bool DeleteFaceTemplate(string deviceId, string cardNo, out string error) + { + error = ""; + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1; + bool ok = HikvisionTemplateInterop.DeleteFaceParam(session.UserId, channel, cardNo); + if (!ok) + { + error = "DeleteFaceTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FACE_PARAM_CFG)"); + _logger.Error(error); + } + return ok; + } + + public bool SetFingerprintTemplate(string deviceId, string cardNo, int readerNo, byte fingerId, byte[] fingerprintData, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + if (string.IsNullOrWhiteSpace(cardNo)) + { + error = "cardNo is required"; + return false; + } + if (fingerprintData == null || fingerprintData.Length == 0) + { + error = "fingerprintData is empty"; + return false; + } + if (fingerprintData.Length > EventByDeploy.CHCNetSDK.MAX_FINGER_PRINT_LEN) + { + error = "fingerprintData exceeds MAX_FINGER_PRINT_LEN"; + return false; + } + if (fingerId == 0 || fingerId > 10) + { + error = "fingerId must be 1..10"; + return false; + } + + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + if (readerNo <= 0) + readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1; + + int handle = -1; + IntPtr condPtr = IntPtr.Zero; + IntPtr inPtr = IntPtr.Zero; + IntPtr outPtr = IntPtr.Zero; + try + { + var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_COND(); + cond.Init(); + cond.dwSize = (uint)Marshal.SizeOf(cond); + cond.dwFingerPrintNum = 1; + cond.dwEnableReaderNo = (uint)readerNo; + cond.byFingerPrintID = fingerId; + CopyUtf8(cardNo, cond.byCardNo); + + condPtr = Marshal.AllocHGlobal((int)cond.dwSize); + Marshal.StructureToPtr(cond, condPtr, false); + + handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig( + session.UserId, + (uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FINGERPRINT, + condPtr, + (int)cond.dwSize, + null, + IntPtr.Zero); + + if (handle < 0) + { + var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError(); + error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FINGERPRINT) failed, err=" + sdkErr; + _logger.Error(error); + return false; + } + + var record = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_RECORD(); + record.Init(); + record.dwSize = (uint)Marshal.SizeOf(record); + CopyUtf8(cardNo, record.byCardNo); + record.dwEnableReaderNo = (uint)readerNo; + record.byFingerPrintID = fingerId; + record.byFingerType = 0; + record.dwFingerPrintLen = (uint)fingerprintData.Length; + Buffer.BlockCopy(fingerprintData, 0, record.byFingerData, 0, fingerprintData.Length); + + var status = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_STATUS(); + status.Init(); + status.dwSize = (uint)Marshal.SizeOf(status); + + inPtr = Marshal.AllocHGlobal((int)record.dwSize); + outPtr = Marshal.AllocHGlobal((int)status.dwSize); + uint outLen = 0; + int attempts = 0; + bool accepted = false; + + while (attempts++ < 300) + { + Marshal.StructureToPtr(record, inPtr, false); + Marshal.StructureToPtr(status, outPtr, false); + + int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig( + handle, + inPtr, + (uint)record.dwSize, + outPtr, + (uint)status.dwSize, + ref outLen); + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT) + { + Thread.Sleep(50); + continue; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH) + { + if (accepted) + return true; + error = "fingerprint config finished before success status"; + return false; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS) + { + status = Marshal.PtrToStructure(outPtr); + if (status.byRecvStatus == 0) + { + accepted = true; + continue; + } + + string msg = DecodeCardNo(status.byErrorMsg); + error = "fingerprint template rejected, recvStatus=" + status.byRecvStatus + + ", readerRecvStatus=" + status.byCardReaderRecvStatus + + ", cardReaderNo=" + status.dwCardReaderNo + + ", msg=" + msg; + _logger.Warn(error); + return false; + } + + if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED || + rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION) + { + var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError(); + error = "fingerprint template remote config failed, rc=" + rc + ", err=" + sdkErr; + _logger.Error(error); + return false; + } + } + + error = "fingerprint template timed out waiting for device response"; + return false; + } + catch (Exception ex) + { + error = "SetFingerprintTemplate exception: " + ex.Message; + _logger.Error(error, ex); + return false; + } + finally + { + try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ } + try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ } + try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ } + try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ } + } + } + + public bool GetFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out byte[] fingerprintData, out string error) + { + error = ""; + fingerprintData = Array.Empty(); + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1; + bool ok = HikvisionTemplateInterop.GetFingerprintParam(session.UserId, channel, cardNo, fingerId, out fingerprintData); + if (!ok) + { + error = "GetFingerprintTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FINGERPRINT_PARAM)"); + _logger.Error(error); + } + return ok; + } + + /// + /// Exports face + fingerprint templates for one SDK user key ( is written to device as card / employee string). + /// Uses NET_DVR_GetDeviceConfig with NET_DVR_GET_FACE_PARAM_CFG and NET_DVR_GET_FINGERPRINT_PARAM (per-finger 1..10). + /// + private bool TryBuildUserTemplatesPayload(string deviceId, string cardNo, out UserTemplateExportPayload payload, out string error) + { + payload = new UserTemplateExportPayload(); + error = ""; + try + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + + if (string.IsNullOrWhiteSpace(cardNo)) + { + error = "cardNo is required (Hikvision enroll key; often the same string as employeeNo)"; + return false; + } + + if (FindSession(deviceId) == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + payload = new UserTemplateExportPayload + { + exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + deviceId = deviceId.Trim(), + cardNo = cardNo.Trim() + }; + + var faceItem = new FaceTemplateExportItem { attempted = true }; + if (GetFaceTemplate(deviceId, cardNo, out var faceBytes, out var faceErr)) + { + if (faceBytes != null && faceBytes.Length > 0) + { + faceItem.present = true; + faceItem.byteLength = faceBytes.Length; + faceItem.dataBase64 = Convert.ToBase64String(faceBytes); + faceItem.error = ""; + } + else + { + faceItem.present = false; + faceItem.error = string.IsNullOrEmpty(faceErr) ? "no face template on device (empty response)" : faceErr; + } + } + else + { + faceItem.present = false; + faceItem.error = string.IsNullOrEmpty(faceErr) ? "face query failed" : faceErr; + } + + payload.face = faceItem; + + for (byte fingerId = 1; fingerId <= 10; fingerId++) + { + var fpItem = new FingerprintTemplateExportItem { fingerId = fingerId, attempted = true }; + if (GetFingerprintTemplate(deviceId, cardNo, fingerId, out var fpData, out var fpErr)) + { + if (fpData != null && fpData.Length > 0) + { + fpItem.present = true; + fpItem.byteLength = fpData.Length; + fpItem.dataBase64 = Convert.ToBase64String(fpData); + fpItem.error = ""; + } + else + { + fpItem.present = false; + fpItem.error = string.IsNullOrEmpty(fpErr) ? "slot empty" : fpErr; + } + } + else + { + fpItem.present = false; + fpItem.error = string.IsNullOrEmpty(fpErr) ? "fingerprint query failed" : fpErr; + } + + payload.fingerprints.Add(fpItem); + } + + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public bool TryExportUserTemplatesToFile(string deviceId, string cardNo, string? outputPath, out string writtenPath, out string error) + { + writtenPath = ""; + error = ""; + try + { + if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var buildErr)) + { + error = buildErr; + return false; + } + + var path = string.IsNullOrWhiteSpace(outputPath) + ? Path.Combine(_config.LogDirectory, BuildDefaultTemplateExportFileName(deviceId, cardNo)) + : outputPath.Trim(); + + var full = Path.GetFullPath(path); + var dir = Path.GetDirectoryName(full); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(payload); + File.WriteAllText(full, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writtenPath = full; + _logger.Info("User template export completed: path=" + full + " device=" + deviceId + " cardNo=" + cardNo); + return true; + } + catch (Exception ex) + { + error = ex.Message; + _logger.Error("TryExportUserTemplatesToFile failed", ex); + return false; + } + } + + public bool TryExportAllUsersTemplatesToFile( + string deviceId, + string? outDir, + int pageSize, + int maxUsers, + CancellationToken cancellationToken, + out string writtenJsonPath, + out string error) + { + writtenJsonPath = ""; + error = ""; + + try + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + if (pageSize <= 0) pageSize = 30; + if (maxUsers <= 0) maxUsers = 10000; + + var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim(); + Directory.CreateDirectory(dir); + + var jsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId)); + writtenJsonPath = jsonPath; + + var faceLogPath = Path.Combine(dir, "face_templates_log.txt"); + var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt"); + + var root = new AllUsersTemplateExportPayload + { + exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + deviceId = deviceId.Trim(), + userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search" + }; + + var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken); + if (!string.IsNullOrEmpty(listErr)) + root.listFetchError = listErr; + + _logger.Info("ExportAllTemplates: device=" + deviceId + ", discoveredUsers=" + cardNos.Count + + ", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr)); + + using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export face templates"); + fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export fingerprint templates"); + + foreach (var cardNo in cardNos) + { + if (cancellationToken.IsCancellationRequested) + break; + + if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var perErr)) + { + // Keep going even if one card payload fails unexpectedly. + payload = new UserTemplateExportPayload + { + exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + deviceId = deviceId.Trim(), + cardNo = cardNo + }; + payload.face = new FaceTemplateExportItem { attempted = true, present = false, byteLength = 0, error = perErr }; + payload.fingerprints = new List(); + } + + root.users.Add(payload); + + // Face log line (one per user) + var f = payload.face; + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + + " device=" + deviceId + + " cardNo=" + cardNo + + " present=" + f.present + + " len=" + f.byteLength + + " error=" + (string.IsNullOrEmpty(f.error) ? "-" : f.error)); + + // Fingerprint log lines (one per finger slot) + foreach (var fp in payload.fingerprints) + { + fpSw.WriteLine(DateTime.UtcNow.ToString("o") + + " device=" + deviceId + + " cardNo=" + cardNo + + " fingerId=" + fp.fingerId + + " present=" + fp.present + + " len=" + fp.byteLength + + " error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error)); + } + } + + var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root); + File.WriteAllText(jsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + _logger.Info("ExportAllTemplates: wrote json path=" + jsonPath + ", users=" + root.users.Count); + + return true; + } + catch (OperationCanceledException) + { + error = "cancelled"; + return false; + } + catch (Exception ex) + { + error = ex.Message; + _logger.Error("TryExportAllUsersTemplatesToFile failed", ex); + return false; + } + } + + private static string BuildAllUsersTemplatesExportFileName(string deviceId) + { + var key = DeviceIdentity.CanonicalLookupKey(deviceId); + if (string.IsNullOrEmpty(key)) + key = (deviceId ?? "").Trim(); + foreach (var ch in Path.GetInvalidFileNameChars()) + key = key.Replace(ch, '_'); + return "all_user_templates_" + key + "_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json"; + } + + private List FetchAllUserCardNosStdXml( + int sessionUserId, + int pageSize, + int maxUsers, + out string listError, + CancellationToken cancellationToken) + { + listError = ""; + var all = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Best-effort pagination. If response doesn't include InfoList, we try alternative wrapper keys. + string searchId = "1"; + int offset = 0; + const int maxPages = 2000; // guardrails + + for (int page = 0; page < maxPages && all.Count < maxUsers; page++) + { + if (cancellationToken.IsCancellationRequested) + break; + + _logger.Info("ExportAllTemplates: user-list page=" + page + ", offset=" + offset + ", maxUsers=" + maxUsers); + + // Try wrapper key #1 + string body1 = BuildJsonUserInfoSearchCond("UserInfoSearchCond", searchId, offset, pageSize); + string raw1 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var sdkErr1); + var pageNos = ExtractUserCardNosFromStdXml(raw1); + + if (pageNos.Count == 0) + { + // Try wrapper key #2 + string body2 = BuildJsonUserInfoSearchCond("AcsUserInfoCond", searchId, offset, pageSize); + string raw2 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var sdkErr2); + pageNos = ExtractUserCardNosFromStdXml(raw2); + + if (pageNos.Count == 0) + { + listError = "user-list fetch returned no employee/card keys. sdkErr1=" + sdkErr1 + ", sdkErr2=" + sdkErr2; + var snippet1 = raw1.Length > 800 ? raw1.Substring(0, 800) : raw1; + var snippet2 = raw2.Length > 800 ? raw2.Substring(0, 800) : raw2; + _logger.Warn("ExportAllTemplates: user-list parse empty; stopping. offset=" + offset + ", listError=" + listError + + ", raw1_snip=\"" + snippet1.Replace("\n", " ").Replace("\r", " ") + "\"" + + ", raw2_snip=\"" + snippet2.Replace("\n", " ").Replace("\r", " ") + "\""); + break; + } + } + + foreach (var c in pageNos) + { + if (string.IsNullOrWhiteSpace(c)) + continue; + all.Add(c.Trim()); + if (all.Count >= maxUsers) + break; + } + + if (pageNos.Count < pageSize) + break; // probably end + + offset += pageSize; + } + + return all.ToList(); + } + + private static string BuildJsonUserInfoSearchCond(string wrapperKey, string searchId, int startOffset, int maxResults) + { + return "{ \"" + wrapperKey + "\": { " + + "\"searchID\": \"" + EscapeJsonStatic(searchId) + "\"," + + "\"searchResultPosition\": " + startOffset + "," + + "\"maxResults\": " + maxResults + + " } }"; + } + + private static string EscapeJsonStatic(string value) + { + if (value == null) return ""; + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + + private static List ExtractUserCardNosFromStdXml(string responseJson) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(responseJson)) + return result; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(responseJson); + if (root == null) + return result; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var keySet = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "employeeNo", + "employeeNoString", + "cardNo", + "cardNoString", + "employeeId", + "userId" + }; + + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (keySet.Contains(kv.Key) && kv.Value != null) + { + string? s = kv.Value is string ss ? ss : kv.Value.ToString(); + if (!string.IsNullOrWhiteSpace(s)) + { + s = s.Trim(); + // Avoid grabbing non-IDs (heuristic: require at least 1 digit and max len) + if (s.Length <= 64 && s.Any(char.IsDigit)) + { + if (seen.Add(s)) + result.Add(s); + } + } + } + + if (kv.Value != null) + stack.Push(kv.Value); + } + } + else if (cur is object[] arr) + { + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + } + catch + { + // Best-effort only. + } + + return result; + } + + private static string BuildDefaultTemplateExportFileName(string deviceId, string cardNo) + { + var key = DeviceIdentity.CanonicalLookupKey(deviceId); + if (string.IsNullOrEmpty(key)) + key = (deviceId ?? "").Trim(); + foreach (var ch in Path.GetInvalidFileNameChars()) + key = key.Replace(ch, '_'); + var safeCard = cardNo ?? ""; + foreach (var ch in Path.GetInvalidFileNameChars()) + safeCard = safeCard.Replace(ch, '_'); + return "user_templates_" + key + "_" + safeCard + "_" + + DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json"; + } + + public bool DeleteFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out string error) + { + error = ""; + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1; + bool ok = HikvisionTemplateInterop.DeleteFingerprintParam(session.UserId, channel, cardNo, fingerId); + if (!ok) + { + error = "DeleteFingerprintTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FINGERPRINT_PARAM)"); + _logger.Error(error); + } + return ok; + } + + public bool SyncTemplatesToDevice( + string deviceId, + string cardNo, + int faceReaderNo, + byte[] faceImageBytes, + int fingerprintReaderNo, + byte fingerId, + byte[] fingerprintData, + out string error) + { + error = ""; + if (faceImageBytes != null && faceImageBytes.Length > 0) + { + if (!SetFaceTemplate(deviceId, cardNo, faceReaderNo, faceImageBytes, out error)) + return false; + } + + if (fingerprintData != null && fingerprintData.Length > 0) + { + if (!SetFingerprintTemplate(deviceId, cardNo, fingerprintReaderNo, fingerId, fingerprintData, out error)) + return false; + } + + return true; + } + + private void StartDevices() + { + // Callback MUST use the same native module as NET_DVR_Init (Common). EventByDeploy uses a different DllImport path → second copy → err=3 NET_DVR_NOINIT. + if (_callbackDelegate == null) + { + _logger.Info("NET_DVR_SetDVRMessageCallBack_V50: registering via Common.CHCNetSDK (before per-device login)."); + _callbackDelegate = new Common.CHCNetSDK.MSGCallBack(AlarmCallback); + bool cbOk = Common.CHCNetSDK.NET_DVR_SetDVRMessageCallBack_V50(0, _callbackDelegate, IntPtr.Zero); + if (!cbOk) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + _logger.Error("NET_DVR_SetDVRMessageCallBack_V50 failed (Common), err=" + err + " (3=NET_DVR_NOINIT if wrong DLL instance)."); + } + else + { + _logger.Info("NET_DVR_SetDVRMessageCallBack_V50 succeeded (Common), index=0, delegate=MSGCallBack. build=" + BuildMarker); + } + } + + // Startup diagnostics: prove whether we have devices to login. + if (_config.Devices == null) + { + _logger.Warn("StartDevices: config.Devices is NULL; skipping all device logins (active sessions will remain 0)."); + return; + } + + _logger.Info("StartDevices: devicesToLoginCount=" + _config.Devices.Count); + if (_config.Devices.Count == 0) + { + _logger.Warn("StartDevices: Devices is empty; skipping all device logins (active sessions will remain 0)."); + return; + } + + for (int i = 0; i < _config.Devices.Count; i++) + { + var d = _config.Devices[i]; + _logger.Info("StartDevices: loadedDevice[" + i + "]: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port + + " Username=\"" + (d.Username ?? "") + "\""); + } + + foreach (var device in _config.Devices) + { + _logger.Info("NET_DVR_Login_V30: connecting " + device.Ip + ":" + device.Port + " user=" + device.Username + " (" + device.DeviceId + ")..."); + + var deviceInfo = new Common.CHCNetSDK.NET_DVR_DEVICEINFO_V30(); + int userId = Common.CHCNetSDK.NET_DVR_Login_V30( + device.Ip, + device.Port, + device.Username, + device.Password, + ref deviceInfo); + + if (userId < 0) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + _logger.Error("NET_DVR_Login_V30 failed for " + device.DeviceId + " (" + device.Ip + ":" + device.Port + "), err=" + err); + continue; + } + + _logger.Info("NET_DVR_Login_V30 succeeded for " + device.DeviceId + " (" + device.Ip + ":" + device.Port + "), userId=" + userId + ". SDK serial=" + + FormatSdkSerial(deviceInfo.sSerialNumber) + ", wDevType=0x" + deviceInfo.wDevType.ToString("X4") + "."); + + LogConfiguredTerminalProfile(device); + + int alarmHandle = -1; + + var alarmParam = new Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50 + { + byLevel = 1, + byAlarmInfoType = 1, + byRetAlarmTypeV40 = 0, + byRetDevInfoVersion = 0, + byRetVQDAlarmType = 0, + byFaceAlarmDetection = 0, + bySupport = 0, + byBrokenNetHttp = 0, + wTaskNo = 0, + byDeployType = 1, // real-time deploy + byRes1 = new byte[3], + byAlarmTypeURL = 0, + byCustomCtrl = 0, + byRes4 = new byte[128] + }; + alarmParam.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50)); + + _logger.Info("NET_DVR_SetupAlarmChan_V50: userId=" + userId + " dwSize=" + alarmParam.dwSize + " byLevel=" + alarmParam.byLevel + + " byAlarmInfoType=" + alarmParam.byAlarmInfoType + " byDeployType=" + alarmParam.byDeployType + " (Common)."); + + alarmHandle = Common.CHCNetSDK.NET_DVR_SetupAlarmChan_V50(userId, ref alarmParam, IntPtr.Zero, 0); + if (alarmHandle < 0) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + _logger.Error("NET_DVR_SetupAlarmChan_V50 failed for " + device.DeviceId + ", err=" + err + ". Session still recorded for login-only APIs (e.g. historical fetch)."); + } + else + { + _logger.Info("NET_DVR_SetupAlarmChan_V50 succeeded for " + device.DeviceId + ", alarmHandle=" + alarmHandle + "."); + } + + _sessions.Add(new DeviceSession(device, userId, alarmHandle)); + _logger.Info("Session REGISTERED: deviceId=" + device.DeviceId + " userId=" + userId + " alarmHandle=" + alarmHandle + + (alarmHandle < 0 ? " (no live alarm; fetch still allowed)" : "")); + } + } + + private void StopDevices() + { + foreach (var s in _sessions.ToArray()) + { + try + { + if (s.AlarmHandle >= 0) + { + if (!Common.CHCNetSDK.NET_DVR_CloseAlarmChan_V30(s.AlarmHandle)) + { + _logger.Warn("NET_DVR_CloseAlarmChan_V30 failed for " + s.Device.DeviceId + ", err=" + + Common.CHCNetSDK.NET_DVR_GetLastError()); + } + else + { + _logger.Info("NET_DVR_CloseAlarmChan_V30 succeeded for " + s.Device.DeviceId + "."); + } + } + } + catch (Exception ex) + { + _logger.Error("Close alarm channel failed for " + s.Device.DeviceId, ex); + } + + try + { + if (s.UserId >= 0) + { + Common.CHCNetSDK.NET_DVR_Logout_V30(s.UserId); + } + } + catch (Exception ex) + { + _logger.Error("Logout failed for " + s.Device.DeviceId, ex); + } + } + + _sessions.Clear(); + } + + private async Task HistoricalSchedulerLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromMinutes(_config.HistoricalFetchIntervalMinutes), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + var to = DateTime.Now; + // Fallback window used only for the first sync (when last-sync file is missing) or if last-sync is unreadable. + var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes); + if (_config.HistoricalFetchLookbackMinutes <= 0) + fallbackFrom = to.AddDays(-1); + + foreach (var s in _sessions.ToArray()) + { + try + { + var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason); + + DateTime from; + if (lastSync.HasValue) + from = lastSync.Value.AddSeconds(1); + else + from = fallbackFrom; + + if (from > to) + { + _logger.Info("Scheduled historical ACS fetch skipped (from > to): device=" + s.Device.DeviceId + + ", from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ", to=" + to.ToString("yyyy-MM-dd HH:mm:ss") + + (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason)); + continue; + } + + DateTime? lastEventTimestamp; + int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp); + _logger.Info("Scheduled historical ACS fetch completed: device=" + s.Device.DeviceId + ", records=" + n + + ", window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + " .. " + to.ToString("yyyy-MM-dd HH:mm:ss") + + ", lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") + + (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason) + + ", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)")); + + // Update last sync only when we actually parsed at least one event and we have a usable event timestamp. + if (n > 0 && lastEventTimestamp.HasValue) + WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value); + } + catch (Exception ex) + { + _logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex); + } + } + } + } + + private string GetLastSyncFilePath(string deviceId) + { + var key = DeviceIdentity.CanonicalLookupKey(deviceId); + if (string.IsNullOrWhiteSpace(key)) + key = (deviceId ?? "").Trim(); + + // Make sure the key is file-system safe. + foreach (var ch in Path.GetInvalidFileNameChars()) + key = key.Replace(ch, '_'); + + return Path.Combine(_config.LogDirectory, "last_sync_acs_" + key + ".txt"); + } + + private DateTime? ReadLastSyncTimestamp(string deviceId, out string reason) + { + reason = ""; + try + { + var path = GetLastSyncFilePath(deviceId); + if (!File.Exists(path)) + { + reason = "last-sync file missing"; + return null; + } + + var raw = File.ReadAllText(path).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + { + reason = "last-sync file empty"; + return null; + } + + // Store as local time in "yyyy-MM-dd HH:mm:ss". + if (DateTime.TryParseExact(raw, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, out var dt)) + return dt; + + if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt)) + return dt; + + reason = "last-sync timestamp parse failed: raw=\"" + raw + "\""; + return null; + } + catch (Exception ex) + { + reason = "last-sync read failed: " + ex.Message; + return null; + } + } + + private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal) + { + var path = GetLastSyncFilePath(deviceId); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory); + var raw = timestampLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + File.WriteAllText(path, raw); + } + + private int FetchAttendanceRecordsCore( + string deviceId, + DateTime fromLocal, + DateTime toLocal, + CancellationToken cancellationToken, + out DateTime? lastEventTimestamp) + { + lastEventTimestamp = null; + var canonicalKey = DeviceIdentity.CanonicalLookupKey(deviceId); + _logger.Info("FetchAttendanceRecordsCore: enter historical fetch; requestedDeviceId=" + deviceId + + " canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey)); + + var session = FindSession(deviceId); + if (session == null) + { + _logger.Error("FetchAttendanceRecordsCore: session lookup FAILED (device not logged in). requestedDeviceId=" + deviceId + + " canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey) + + "; activeSessionIds=" + string.Join(", ", _sessions.Select(s => s.Device.DeviceId))); + return 0; + } + + _logger.Info("FetchAttendanceRecordsCore: session lookup OK userId=" + session.UserId + " sessionDeviceId=" + session.Device.DeviceId); + + if (toLocal < fromLocal) + { + _logger.Warn("FetchAttendanceRecordsCore: toLocal < fromLocal; swapping."); + (fromLocal, toLocal) = (toLocal, fromLocal); + } + + _logger.Info("Historical ACS fetch START device=" + deviceId + " userId=" + session.UserId + + " (session from login only; alarm channel not required) from=" + fromLocal.ToString("yyyy-MM-dd HH:mm:ss") + + " to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss")); + + // Prioritize the officially documented STDXMLConfig + ISAPI AccessControl path for DS-K1T642MFW. + // If anything fails (SDK call error, parse error, unexpected response), we fall back to NET_DVR_GET_ACS_EVENT. + if (IsStdXmlPreferredDevice(session.Device)) + { + bool attemptedStdXml; + DateTime? stdLastEventTs; + int stdXmlParsed = TryStdXmlAcsFetchAndEnqueue( + session, + fromLocal, + toLocal, + cancellationToken, + out attemptedStdXml, + out stdLastEventTs); + if (attemptedStdXml) + { + lastEventTimestamp = stdLastEventTs; + return stdXmlParsed; + } + } + + DateTime? maxEventTs = null; + + var cond = new CHCNetSDK.NET_DVR_ACS_EVENT_COND(); + cond.Init(); + cond.dwSize = (uint)Marshal.SizeOf(cond); + cond.dwMajor = _config.AcsHistoryMajor; + cond.dwMinor = _config.AcsHistoryMinor; + cond.struStartTime = ToDvrTime(fromLocal); + cond.struEndTime = ToDvrTime(toLocal); + cond.byPicEnable = 0; + cond.szMonitorID = ""; + cond.wInductiveEventType = 65535; + + IntPtr condPtr = IntPtr.Zero; + int handle = -1; + int total = 0; + int parsedOk = 0; + int parseFail = 0; + + try + { + condPtr = Marshal.AllocHGlobal((int)cond.dwSize); + Marshal.StructureToPtr(cond, condPtr, false); + + handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig( + session.UserId, + (uint)CHCNetSDK.NET_DVR_GET_ACS_EVENT, + condPtr, + (int)cond.dwSize, + null, + IntPtr.Zero); + + if (handle < 0) + { + _logger.Error("NET_DVR_StartRemoteConfig(NET_DVR_GET_ACS_EVENT, Common) failed, err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); + return 0; + } + + int cfgSize = Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_CFG)); + IntPtr cfgPtr = Marshal.AllocHGlobal(cfgSize); + try + { + PrepareAcsEventCfgPointer(cfgPtr, cfgSize); + + while (!cancellationToken.IsCancellationRequested) + { + int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, cfgPtr, (uint)cfgSize); + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS) + { + var cfg = Marshal.PtrToStructure(cfgPtr); + total++; + if (TryBuildAttendanceFromAcsCfg(session, ref cfg, out var ev)) + { + EnqueueAttendance(ev, "Historical fetch parsed"); + parsedOk++; + + if (ev != null) + { + if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value) + maxEventTs = ev.Timestamp; + } + } + else + { + parseFail++; + } + + continue; + } + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT) + { + Thread.Sleep(200); + continue; + } + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH) + { + Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); + handle = -1; + break; + } + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FAILED) + { + _logger.Error("NET_DVR_GetNextRemoteConfig failed status, err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); + Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); + handle = -1; + break; + } + + _logger.Warn("NET_DVR_GetNextRemoteConfig unknown status=" + status + ", err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); + Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); + handle = -1; + break; + } + } + finally + { + Marshal.FreeHGlobal(cfgPtr); + } + } + finally + { + if (condPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(condPtr); + } + + if (handle >= 0) + { + try { Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ } + } + } + + _logger.Info("Historical ACS fetch DONE device=" + deviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail); + lastEventTimestamp = maxEventTs; + return parsedOk; + } + + private static bool IsStdXmlPreferredDevice(HikvisionAttendanceWindowsService.DeviceConfig device) + { + if (device == null) + return false; + + // Prefer model/identity strings; DeviceId for this project usually starts with "DS-K1T642MFW-...". + var m = device.Model ?? string.Empty; + if (m.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + var did = device.DeviceId ?? string.Empty; + if (did.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + var serial = device.SerialNumber ?? string.Empty; + if (serial.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + return false; + } + + /// + /// STDXMLConfig diagnostic path using ISAPI/AccessControl endpoints. + /// Returns parsed attendance event count, and sets attemptedStdXml=true only if we successfully parsed at least one event. + /// + private int TryStdXmlAcsFetchAndEnqueue( + DeviceSession session, + DateTime fromLocal, + DateTime toLocal, + CancellationToken cancellationToken, + out bool attemptedStdXml, + out DateTime? lastEventTimestamp) + { + attemptedStdXml = false; + lastEventTimestamp = null; + + try + { + // Step 1-3: capabilities (raw JSON/XML is logged). + // Step 5-6: build request bodies and POST. + // Step 7: log request URL/body + raw response + parsed response status. + + uint major = _config.AcsHistoryMajor; + uint minor = _config.AcsHistoryMinor; + + //var fromUtc = fromLocal.ToUniversalTime(); + //var toUtc = toLocal.ToUniversalTime(); + + //string startTime = fromUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); + //string endTime = toUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); + + string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; + string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; + + var searchId = "1"; + int searchResultPosition = 0; + int maxResults = 30; + + string r1 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/capabilities?format=json", null, out var e1); + _logger.Info("STDXMLConfig step1 rawResponse: " + TruncateForLog(r1, 120_000)); + if (!string.IsNullOrEmpty(e1)) + _logger.Warn("STDXMLConfig step1 SDK error: " + e1); + + cancellationToken.ThrowIfCancellationRequested(); + + string r2 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEvent/capabilities?format=json", null, out var e2); + _logger.Info("STDXMLConfig step2 rawResponse: " + TruncateForLog(r2, 120_000)); + if (!string.IsNullOrEmpty(e2)) + _logger.Warn("STDXMLConfig step2 SDK error: " + e2); + + cancellationToken.ThrowIfCancellationRequested(); + + string r3 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEventTotalNum/capabilities?format=json", null, out var e3); + _logger.Info("STDXMLConfig step3 rawResponse: " + TruncateForLog(r3, 120_000)); + if (!string.IsNullOrEmpty(e3)) + _logger.Warn("STDXMLConfig step3 SDK error: " + e3); + + // Step 5: build JSON_AcsEventTotalNumCond and POST. + string jsonTotalNumCond = BuildJsonAcsEventTotalNumCond(searchId, major, minor, startTime, endTime); + string totalNumUri = "/ISAPI/AccessControl/AcsEventTotalNum?format=json"; + string totalNumRequestUrl = "POST " + totalNumUri; + _logger.Info("STDXMLConfig step5 requestUrl=" + totalNumRequestUrl + " requestBody=" + TruncateForLog(jsonTotalNumCond, 120_000)); + + string rTotalNum = StdXmlCall(session.UserId, "POST", totalNumUri, jsonTotalNumCond, out var eTotalNum); + _logger.Info("STDXMLConfig step5 rawResponse: " + TruncateForLog(rTotalNum, 120_000)); + if (!string.IsNullOrEmpty(eTotalNum)) + _logger.Warn("STDXMLConfig step5 SDK error: " + eTotalNum); + _logger.Info("STDXMLConfig step5 parsedResponseStatus: " + ParseStdXmlResponseStatus(rTotalNum)); + + cancellationToken.ThrowIfCancellationRequested(); + + // Step 6: build JSON_AcsEventCond and POST. + string jsonAcsEventCond = BuildJsonAcsEventCond(searchId, searchResultPosition, maxResults, major, minor, startTime, endTime); + string acsEventUri = "/ISAPI/AccessControl/AcsEvent?format=json"; + string acsEventRequestUrl = "POST " + acsEventUri; + _logger.Info("STDXMLConfig step6 requestUrl=" + acsEventRequestUrl + " requestBody=" + TruncateForLog(jsonAcsEventCond, 120_000)); + + string rAcsEvent = StdXmlCall(session.UserId, "POST", acsEventUri, jsonAcsEventCond, out var eAcsEvent); + _logger.Info("STDXMLConfig step6 rawResponse: " + TruncateForLog(rAcsEvent, 120_000)); + if (!string.IsNullOrEmpty(eAcsEvent)) + _logger.Warn("STDXMLConfig step6 SDK error: " + eAcsEvent); + _logger.Info("STDXMLConfig step6 parsedResponseStatus: " + ParseStdXmlResponseStatus(rAcsEvent)); + + cancellationToken.ThrowIfCancellationRequested(); + + // Step 10 (goal): map returned fields into our attendance pipeline. + var acsEventInfoList = ExtractAcsEventInfoList(rAcsEvent); + _logger.Info("STDXMLConfig extracted events: " + acsEventInfoList.Count); + + string eventName = MapAcsEventName(major, minor); + if (string.IsNullOrWhiteSpace(eventName)) + eventName = "MAJOR_" + major + "_MINOR_" + minor; + string eventType = AcsAttendanceParser.MapMajorCategory(major) + "/" + minor.ToString("X"); + + // Keep success inference consistent with existing pipeline rules. + bool isSuccessByMinorRule = AcsAttendanceParser.ResolveIsSuccess(major, minor, eventName); + + int total = 0; + int parsedOk = 0; + int parseFail = 0; + DateTime? maxEventTs = null; + + foreach (var info in acsEventInfoList) + { + total++; + if (!TryBuildAttendanceFromStdAcsInfo(session.Device, info, major, minor, eventName, eventType, isSuccessByMinorRule, out var ev)) + { + parseFail++; + continue; + } + + EnqueueAttendance(ev, "Historical STDXMLConfig parsed"); + parsedOk++; + + if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value) + maxEventTs = ev.Timestamp; + } + + _logger.Info("STDXMLConfig fetch DONE device=" + session.Device.DeviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail); + + attemptedStdXml = parsedOk > 0; + if (maxEventTs.HasValue) + // STDXML timestamps are parsed as UTC (we parse the device time with offset, then convert to universal). + // Convert back to local time so incremental cursor stays consistent with scheduler's local `from`/`to`. + lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Utc).ToLocalTime(); + else + lastEventTimestamp = null; + return parsedOk; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Error("STDXMLConfig diagnostic fetch failed; falling back to NET_DVR_GET_ACS_EVENT", ex); + attemptedStdXml = false; + lastEventTimestamp = null; + return 0; + } + } + + private static string BuildJsonAcsEventTotalNumCond(string searchId, uint major, uint minor, string startTimeUtc, string endTimeUtc) + { + return "{ \"AcsEventTotalNumCond\": { " + + "\"searchID\": \"" + EscapeJson(searchId) + "\"," + + "\"major\": " + major + "," + + "\"minor\": " + minor + "," + + "\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," + + "\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" + + " } }"; + } + + private static string BuildJsonAcsEventCond( + string searchId, + int searchResultPosition, + int maxResults, + uint major, + uint minor, + string startTimeUtc, + string endTimeUtc) + { + //string minorPart = minor != 0 ? ",\"minor\": " + minor : ""; + string minorPart = ",\"minor\": " + minor; + return "{ \"AcsEventCond\": { " + + "\"searchID\": \"" + EscapeJson(searchId) + "\"," + + "\"searchResultPosition\": " + searchResultPosition + "," + + "\"maxResults\": " + maxResults + "," + + "\"major\": " + major + + minorPart + "," + + "\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," + + "\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" + + " } }"; + } + + private static string EscapeJson(string value) + { + if (value == null) + return ""; + + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + + private string StdXmlCall(int userId, string method, string uri, string? postBody, out string sdkError) + { + sdkError = ""; + string raw = ""; + + IntPtr ptrUrl = IntPtr.Zero; + IntPtr ptrIn = IntPtr.Zero; + IntPtr ptrInput = IntPtr.Zero; + IntPtr ptrOutBuf = IntPtr.Zero; + IntPtr ptrOutput = IntPtr.Zero; + + try + { + // Input + var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT(); + input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)); + + string requestUrl = method + " " + uri; + ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl); + input.lpRequestUrl = ptrUrl; + input.dwRequestUrlLen = (uint)requestUrl.Length; + input.dwRecvTimeOut = 5000; // ms + + if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody)) + { + ptrIn = Marshal.StringToCoTaskMemAnsi(postBody); + input.lpInBuffer = ptrIn; + input.dwInBufferSize = (uint)postBody.Length; + } + else + { + input.lpInBuffer = IntPtr.Zero; + input.dwInBufferSize = 0; + } + + ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT))); + Marshal.StructureToPtr(input, ptrInput, false); + + // Output + var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT(); + output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)); + + const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw JSON/XML responses + ptrOutBuf = Marshal.AllocHGlobal(outBufSize); + output.lpOutBuffer = ptrOutBuf; + output.dwOutBufferSize = (uint)outBufSize; + output.lpStatusBuffer = ptrOutBuf; + output.dwStatusSize = (uint)outBufSize; + + ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT))); + Marshal.StructureToPtr(output, ptrOutput, false); + + bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput); + if (!ok) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + sdkError = "NET_DVR_STDXMLConfig failed err=" + err; + } + + var outAfter = Marshal.PtrToStructure(ptrOutput); + int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize); + if (returnedSize > 0 && returnedSize <= outBufSize) + { + byte[] bytes = new byte[returnedSize]; + Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize); + raw = Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim(); + } + else + { + raw = Marshal.PtrToStringAnsi(ptrOutBuf) ?? ""; + } + } + finally + { + if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl); + if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn); + if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput); + if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput); + if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf); + } + + return raw; + } + + private byte[] StdXmlCallBytes(int userId, string method, string uri, string? postBody, out string sdkError) + { + sdkError = ""; + byte[] bytes = Array.Empty(); + + IntPtr ptrUrl = IntPtr.Zero; + IntPtr ptrIn = IntPtr.Zero; + IntPtr ptrInput = IntPtr.Zero; + IntPtr ptrOutput = IntPtr.Zero; + IntPtr ptrOutBuf = IntPtr.Zero; + + try + { + var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT(); + input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)); + + string requestUrl = method + " " + uri; + ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl); + input.lpRequestUrl = ptrUrl; + input.dwRequestUrlLen = (uint)requestUrl.Length; + input.dwRecvTimeOut = 5000; // ms + + if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody)) + { + ptrIn = Marshal.StringToCoTaskMemAnsi(postBody); + input.lpInBuffer = ptrIn; + input.dwInBufferSize = (uint)postBody.Length; + } + else + { + input.lpInBuffer = IntPtr.Zero; + input.dwInBufferSize = 0; + } + + ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT))); + Marshal.StructureToPtr(input, ptrInput, false); + + var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT(); + output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)); + + const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw responses + ptrOutBuf = Marshal.AllocHGlobal(outBufSize); + output.lpOutBuffer = ptrOutBuf; + output.dwOutBufferSize = (uint)outBufSize; + output.lpStatusBuffer = ptrOutBuf; + output.dwStatusSize = (uint)outBufSize; + + ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT))); + Marshal.StructureToPtr(output, ptrOutput, false); + + bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput); + if (!ok) + { + var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + sdkError = "NET_DVR_STDXMLConfig failed err=" + err; + } + + var outAfter = Marshal.PtrToStructure(ptrOutput); + int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize); + if (returnedSize > 0 && returnedSize <= outBufSize) + { + bytes = new byte[returnedSize]; + Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize); + } + else + { + bytes = Array.Empty(); + } + } + finally + { + if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl); + if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn); + if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput); + if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput); + if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf); + } + + return bytes; + } + + private static int? TryExtractErrorCode17(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + // Best-effort: Hikvision error responses often include "errorCode": 17 or 17 + // so we do a small heuristic scan. + try + { + int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return null; + + // Search the nearest integer token after the marker. + var tail = text.Substring(idx); + // Simple tokenization: keep digits and '-' only. + var sb = new StringBuilder(); + for (int i = 0; i < tail.Length; i++) + { + char c = tail[i]; + if (char.IsDigit(c) || c == '-') + sb.Append(c); + else if (sb.Length > 0) + break; + } + + if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code)) + { + if (code == 17) + return 17; + return code; + } + } + catch + { + // ignore + } + + return null; + } + + private static bool LooksLikeJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + var t = text.TrimStart(); + return t.StartsWith("{") || t.StartsWith("["); + } + + private bool TryParseFingerprintItemsFromIsapiResponse(byte[] responseBytes, out List items, out string parseError) + { + items = new List(); + parseError = ""; + + if (responseBytes == null || responseBytes.Length == 0) + return false; + + // Some firmware returns XML/JSON error messages as text; others return binary blob. + string text; + try + { + text = Encoding.UTF8.GetString(responseBytes); + } + catch + { + return false; + } + + // If it's not JSON, we treat it as a single opaque binary fingerprint template. + if (!LooksLikeJson(text)) + { + items.Add(new FingerprintTemplateExportItem + { + fingerId = 1, + attempted = true, + present = true, + byteLength = responseBytes.Length, + dataBase64 = Convert.ToBase64String(responseBytes), + error = "" + }); + return true; + } + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(text); + if (root == null) + return false; + + static bool LooksLikeBase64(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return false; + var t = s.Trim(); + if (t.Length < 16) + return false; + // allow both standard and URL-safe base64 alphabets. + for (int i = 0; i < t.Length; i++) + { + char c = t[i]; + if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=') + continue; + return false; + } + return true; + } + + // Best-effort traversal: find dictionaries with a finger id and a data/base64-like field. + var stack = new Stack(); + stack.Push(root); + + var candidateFingerIds = new HashSet(); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (kv.Value is Dictionary nested) + stack.Push(nested); + if (kv.Value is object[] arr) + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + + // attempt to parse finger id from current dictionary + int? fingerId = null; + if (TryGetIntNullable(d, "fingerPrintID", out var fid1)) + fingerId = fid1; + else if (TryGetIntNullable(d, "fingerId", out var fid2)) + fingerId = fid2; + else if (TryGetIntNullable(d, "fingerprintId", out var fid3)) + fingerId = fid3; + + // attempt to find any base64-looking string + if (fingerId.HasValue && fingerId.Value >= 0) + { + string? base64 = null; + + // Fast paths for known key names. + if (d.TryGetValue("dataBase64", out var db) && db is string s1 && LooksLikeBase64(s1)) + base64 = s1; + else if (d.TryGetValue("fingerData", out var fd) && fd is string s2 && LooksLikeBase64(s2)) + base64 = s2; + + // Broader heuristic: any base64-looking value in keys that suggest template/bio content. + if (base64 == null) + { + foreach (var kv in d) + { + if (kv.Value is not string ss) + continue; + if (!LooksLikeBase64(ss)) + continue; + var key = kv.Key ?? ""; + var k = key.ToLowerInvariant(); + if (k.Contains("base64") || k.Contains("finger") || k.Contains("template") || k.Contains("data")) + { + base64 = ss; + break; + } + } + } + + if (!string.IsNullOrWhiteSpace(base64)) + { + candidateFingerIds.Add(fingerId.Value); + items.Add(new FingerprintTemplateExportItem + { + fingerId = (byte)Math.Max(0, Math.Min(10, fingerId.Value)), + attempted = true, + present = true, + dataBase64 = base64, + byteLength = -1, + error = "" + }); + } + } + } + else if (cur is object[] arr) + { + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + + if (items.Count > 0) + return true; + + parseError = "fingerprint JSON parse found no per-finger template objects"; + return false; + } + catch (Exception ex) + { + parseError = ex.Message; + return false; + } + } + + private bool TryGetIntNullable(Dictionary d, string key, out int value) + { + value = 0; + if (!d.TryGetValue(key, out var v) || v == null) + return false; + if (v is int i) { value = i; return true; } + if (v is long l) { value = (int)l; return true; } + if (v is double dd) { value = (int)dd; return true; } + if (v is string s && int.TryParse(s, out var p)) { value = p; return true; } + return false; + } + + private static string ExtractIsapiStatusSummary(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return "status=(empty)"; + + string statusCode = ""; + string statusString = ""; + string subStatusCode = ""; + string errorCode = ""; + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(text); + if (root != null) + { + if (TryFindInt(root, new[] { "statusCode" }, out var sCode)) statusCode = sCode.ToString(CultureInfo.InvariantCulture); + if (TryFindString(root, new[] { "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var sStr)) statusString = sStr; + if (TryFindString(root, new[] { "subStatusCode" }, out var sub)) subStatusCode = sub; + if (TryFindInt(root, new[] { "errorCode" }, out var e)) errorCode = e.ToString(CultureInfo.InvariantCulture); + } + } + catch + { + // ignore and fallback to regex below + } + + if (string.IsNullOrEmpty(statusCode)) + { + var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusCode\"\\s*:\\s*(?-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) statusCode = m.Groups["v"].Value; + } + if (string.IsNullOrEmpty(statusString)) + { + var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusString\"\\s*:\\s*\"(?[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) statusString = m.Groups["v"].Value; + } + if (string.IsNullOrEmpty(subStatusCode)) + { + var m = System.Text.RegularExpressions.Regex.Match(text, "\"subStatusCode\"\\s*:\\s*\"(?[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) subStatusCode = m.Groups["v"].Value; + } + if (string.IsNullOrEmpty(errorCode)) + { + var m = System.Text.RegularExpressions.Regex.Match(text, "\"errorCode\"\\s*:\\s*(?-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) errorCode = m.Groups["v"].Value; + } + + return "statusCode=" + (string.IsNullOrEmpty(statusCode) ? "-" : statusCode) + + ", statusString=" + (string.IsNullOrEmpty(statusString) ? "-" : statusString) + + ", subStatusCode=" + (string.IsNullOrEmpty(subStatusCode) ? "-" : subStatusCode) + + ", errorCode=" + (string.IsNullOrEmpty(errorCode) ? "-" : errorCode); + } + + private static string ToOneLineSnippet(string? text, int maxLen = 800) + { + if (string.IsNullOrWhiteSpace(text)) + return ""; + var s = text.Replace("\r", " ").Replace("\n", " ").Trim(); + if (s.Length > maxLen) + s = s.Substring(0, maxLen); + return s; + } + + private string ProbeCapabilityAndLog(int userId, string uri, string featureName, StreamWriter sw) + { + var raw = StdXmlCall(userId, "GET", uri, null, out var sdkErr); + var status = ExtractIsapiStatusSummary(raw); + var snippet = ToOneLineSnippet(raw); + sw.WriteLine(DateTime.UtcNow.ToString("o") + " feature=" + featureName + " capabilityUri=" + uri + + " sdkErr=" + (string.IsNullOrEmpty(sdkErr) ? "-" : sdkErr) + + " " + status + " raw_snip=\"" + snippet + "\""); + return raw; + } + + private bool TrySearchCardInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error) + { + response = ""; + error = ""; + var body = "{ \"CardInfoSearchCond\": { " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 20, " + + "\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" + + " } }"; + response = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/CardInfo/Search?format=json", body, out var sdkErr); + error = sdkErr; + return !string.IsNullOrWhiteSpace(response); + } + + private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError) + { + return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError); + } + + private string CardInfoSetUpIsapi(int userId, string jsonBody, out string sdkError) + { + return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/SetUp?format=json", jsonBody, out sdkError); + } + + private string CardInfoRecordIsapi(int userId, string jsonBody, out string sdkError) + { + return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Record?format=json", jsonBody, out sdkError); + } + + private string CardInfoModifyIsapi(int userId, string jsonBody, out string sdkError) + { + return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Modify?format=json", jsonBody, out sdkError); + } + + private string CardInfoDeleteIsapi(int userId, string jsonBody, out string sdkError) + { + return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Delete?format=json", jsonBody, out sdkError); + } + + private bool TryFetchFingerprintTemplatesViaRemoteConfig( + DeviceSession session, + string cardNo, + out List fingerprints, + out string debug) + { + fingerprints = new List(); + debug = ""; + + IntPtr condPtr = IntPtr.Zero; + IntPtr outPtr = IntPtr.Zero; + int handle = -1; + try + { + // Use Common SDK instance (same as NET_DVR_Init) and command aligned with Common struct family. + const uint NET_DVR_GET_FINGERPRINT_CFG = 2150; + + var cond = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_INFO_COND(); + cond.dwSize = (uint)Marshal.SizeOf(cond); + cond.byCardNo = new byte[32]; + cond.byEnableCardReader = new byte[512]; + cond.byRes1 = new byte[26]; + cond.dwFingerPrintNum = 0xFFFFFFFF; // all fingerprints + cond.byFingerPrintID = 0xFF; // all finger ids + cond.byCallbackMode = 0; // sync pull mode + CopyUtf8(cardNo, cond.byCardNo); + + int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1; + if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length) + cond.byEnableCardReader[readerNo - 1] = 1; + else if (cond.byEnableCardReader.Length > 0) + cond.byEnableCardReader[0] = 1; + + condPtr = Marshal.AllocHGlobal((int)cond.dwSize); + Marshal.StructureToPtr(cond, condPtr, false); + + handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig( + session.UserId, + NET_DVR_GET_FINGERPRINT_CFG, + condPtr, + (int)cond.dwSize, + null, + IntPtr.Zero); + + if (handle < 0) + { + int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError()); + debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FINGERPRINT_CFG) failed err=" + err; + return false; + } + + var outCfg = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG(); + outCfg.dwSize = (uint)Marshal.SizeOf(outCfg); + outCfg.byCardNo = new byte[32]; + outCfg.byEnableCardReader = new byte[512]; + outCfg.byRes1 = new byte[30]; + outCfg.byFingerData = new byte[Common.CHCNetSDK.MAX_FINGER_PRINT_LEN]; + outCfg.byRes = new byte[64]; + int outSize = Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG)); + outPtr = Marshal.AllocHGlobal(outSize); + + int rows = 0; + int loops = 0; + while (loops++ < 3000) + { + Marshal.StructureToPtr(outCfg, outPtr, false); + int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize); + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS) + { + rows++; + var row = Marshal.PtrToStructure(outPtr); + int len = (int)Math.Min(row.dwFingerPrintLen, (uint)(row.byFingerData?.Length ?? 0)); + if (len > 0) + { + var bytes = new byte[len]; + Buffer.BlockCopy(row.byFingerData, 0, bytes, 0, len); + fingerprints.Add(new FingerprintTemplateExportItem + { + fingerId = row.byFingerPrintID, + attempted = true, + present = true, + byteLength = len, + dataBase64 = Convert.ToBase64String(bytes), + error = "" + }); + } + continue; + } + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT) + { + Thread.Sleep(80); + continue; + } + + if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH) + { + debug = "fingerprint remote-config finished rows=" + rows; + return true; + } + + int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError()); + debug = "NET_DVR_GetNextRemoteConfig(fingerprint) status=" + status + ", err=" + err; + return false; + } + + debug = "fingerprint remote-config timed out"; + return false; + } + finally + { + try { if (handle >= 0) Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ } + try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ } + try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ } + } + } + + private bool TryFetchFaceTemplateViaRemoteConfig( + DeviceSession session, + string cardNo, + out FaceTemplateExportItem faceItem, + out string debug) + { + faceItem = new FaceTemplateExportItem + { + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = "" + }; + debug = ""; + + IntPtr condPtr = IntPtr.Zero; + IntPtr outPtr = IntPtr.Zero; + int handle = -1; + try + { + var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_COND(); + cond.Init(); + cond.dwSize = (uint)Marshal.SizeOf(cond); + cond.dwFaceNum = 0xFFFFFFFF; // all faces for this user + cond.byFaceID = 0xFF; // all face IDs + CopyUtf8(cardNo, cond.byCardNo); + int readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1; + if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length) + cond.byEnableCardReader[readerNo - 1] = 1; + else if (cond.byEnableCardReader.Length > 0) + cond.byEnableCardReader[0] = 1; + + condPtr = Marshal.AllocHGlobal((int)cond.dwSize); + Marshal.StructureToPtr(cond, condPtr, false); + + handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig( + session.UserId, + (uint)EventByDeploy.CHCNetSDK.NET_DVR_GET_FACE, + condPtr, + (int)cond.dwSize, + null, + IntPtr.Zero); + + if (handle < 0) + { + int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError()); + debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FACE) failed err=" + err; + return false; + } + + var outCfg = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG(); + outCfg.Init(); + outCfg.dwSize = (uint)Marshal.SizeOf(outCfg); + int outSize = Marshal.SizeOf(typeof(EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG)); + outPtr = Marshal.AllocHGlobal(outSize); + + int rows = 0; + int loops = 0; + while (loops++ < 3000) + { + Marshal.StructureToPtr(outCfg, outPtr, false); + int status = EventByDeploy.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize); + if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS) + { + rows++; + var row = Marshal.PtrToStructure(outPtr); + int len = (int)row.dwFaceLen; + if (len > 0 && row.pFaceBuffer != IntPtr.Zero) + { + var bytes = new byte[len]; + Marshal.Copy(row.pFaceBuffer, bytes, 0, len); + faceItem.present = true; + faceItem.byteLength = len; + faceItem.dataBase64 = Convert.ToBase64String(bytes); + faceItem.error = ""; + // Keep reading until FINISH so SDK state is clean. + } + continue; + } + + if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT) + { + Thread.Sleep(80); + continue; + } + + if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH) + { + debug = "face remote-config finished rows=" + rows; + if (!faceItem.present) + faceItem.error = "face not enrolled"; + return true; + } + + int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError()); + debug = "NET_DVR_GetNextRemoteConfig(face) status=" + status + ", err=" + err; + if (err == 17) + faceItem.error = "missing(errorCode17)"; + else + faceItem.error = debug; + return false; + } + + debug = "face remote-config timed out"; + faceItem.error = debug; + return false; + } + finally + { + try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ } + try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ } + try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ } + } + } + + private sealed class MultipartMixedPart + { + public string headersText = ""; + public string contentType = ""; + public string? contentDispositionName = null; + public byte[] bodyBytes = Array.Empty(); + } + + private static string DecodeBytesForText(byte[] bytes) + { + // Prefer UTF-8, but fall back to ISO-8859-1 to avoid losing ASCII fragments + // (e.g., JSON metadata + multipart boundaries). + try + { + return Encoding.UTF8.GetString(bytes); + } + catch + { + return Encoding.GetEncoding("iso-8859-1").GetString(bytes); + } + } + + private static int? TryExtractErrorCodeAny(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + // Common shapes: "errorCode": 17 or 17 + try + { + int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return null; + + var tail = text.Substring(idx); + var sb = new StringBuilder(); + for (int i = 0; i < tail.Length; i++) + { + char c = tail[i]; + if (char.IsDigit(c) || c == '-') + sb.Append(c); + else if (sb.Length > 0) + break; + } + + if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code)) + return code; + } + catch + { + // ignore + } + + return null; + } + + private static bool TryExtractMultipartBoundary(byte[] responseBytes, out string boundary) + { + boundary = ""; + if (responseBytes == null || responseBytes.Length == 0) + return false; + + // Boundary usually lives in the ASCII preamble. We only need to look at the first chunk. + int scanLen = Math.Min(responseBytes.Length, 16 * 1024); + var head = responseBytes.Take(scanLen).ToArray(); + var headText = DecodeBytesForText(head); + + // Examples: + // - boundary=someBoundary + // - boundary="someBoundary" + var m = System.Text.RegularExpressions.Regex.Match( + headText, + "boundary\\s*=\\s*\"?(?[^;\\s\\\"]+)\"?", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (!m.Success || string.IsNullOrWhiteSpace(m.Groups["b"].Value)) + return false; + + boundary = m.Groups["b"].Value.Trim(); + return !string.IsNullOrWhiteSpace(boundary); + } + + private static int IndexOfBytes(byte[] haystack, byte[] needle, int startIndex) + { + if (needle.Length == 0) + return -1; + for (int i = startIndex; i <= haystack.Length - needle.Length; i++) + { + bool ok = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) + { + ok = false; + break; + } + } + if (ok) + return i; + } + return -1; + } + + private static bool TryParseMultipartMixed(byte[] responseBytes, out List parts, out string parseError) + { + parts = new List(); + parseError = ""; + + if (responseBytes == null || responseBytes.Length == 0) + return false; + + if (!TryExtractMultipartBoundary(responseBytes, out var boundary)) + return false; + + // Find boundary occurrences. + var boundaryMarker = Encoding.ASCII.GetBytes("--" + boundary); + int pos = 0; + var positions = new List(); + while (true) + { + int p = IndexOfBytes(responseBytes, boundaryMarker, pos); + if (p < 0) + break; + positions.Add(p); + pos = p + boundaryMarker.Length; + if (positions.Count > 2000) // guardrail + break; + } + + if (positions.Count < 2) + return false; + + for (int i = 0; i < positions.Count - 1; i++) + { + int segStart = positions[i] + boundaryMarker.Length; + int segEnd = positions[i + 1]; + + if (segEnd <= segStart) + continue; + + var segment = new byte[segEnd - segStart]; + Buffer.BlockCopy(responseBytes, segStart, segment, 0, segment.Length); + + // Trim leading CRLF + int trimStart = 0; + while (trimStart < segment.Length && (segment[trimStart] == (byte)'\r' || segment[trimStart] == (byte)'\n')) + trimStart++; + if (trimStart > 0) + segment = segment.Skip(trimStart).ToArray(); + + // Trim trailing CRLF + int trimEnd = segment.Length; + while (trimEnd > 0 && (segment[trimEnd - 1] == (byte)'\r' || segment[trimEnd - 1] == (byte)'\n')) + trimEnd--; + if (trimEnd != segment.Length) + segment = segment.Take(trimEnd).ToArray(); + + if (segment.Length == 0) + continue; + + // Split headers vs body: look for CRLFCRLF or LFLF. + int headerEnd = IndexOfBytes(segment, new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' }, 0); + int lfHeaderEnd = -1; + if (headerEnd < 0) + lfHeaderEnd = IndexOfBytes(segment, new byte[] { (byte)'\n', (byte)'\n' }, 0); + + int splitPos = headerEnd >= 0 ? headerEnd : lfHeaderEnd; + if (splitPos < 0) + { + // No headers: treat as a raw body. + parts.Add(new MultipartMixedPart + { + headersText = "", + contentType = "", + contentDispositionName = null, + bodyBytes = segment + }); + continue; + } + + var headersBytes = segment.Take(splitPos).ToArray(); + var bodyBytes = segment.Skip(splitPos + (headerEnd >= 0 ? 4 : 2)).ToArray(); + + var headersText = DecodeBytesForText(headersBytes); + + // content-type + string contentType = ""; + var mType = System.Text.RegularExpressions.Regex.Match( + headersText, + "Content-Type\\s*:\\s*(?[^;\\r\\n]+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (mType.Success) + contentType = mType.Groups["t"].Value.Trim(); + + // content-disposition name + string? dispName = null; + var mName = System.Text.RegularExpressions.Regex.Match( + headersText, + "Content-Disposition[\\s\\S]*?name\\s*=\\s*\"?(?[^\";\\r\\n]+)\"?", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (mName.Success) + dispName = mName.Groups["n"].Value.Trim(); + + parts.Add(new MultipartMixedPart + { + headersText = headersText, + contentType = contentType, + contentDispositionName = dispName, + bodyBytes = bodyBytes + }); + } + + return parts.Count > 0; + } + + private static bool TryExtractJsonTextFromMultipart(byte[] responseBytes, out string jsonText, out string jsonContentType, out string parseError) + { + jsonText = ""; + jsonContentType = ""; + parseError = ""; + + jsonText = ""; + jsonContentType = ""; + parseError = ""; + + if (!TryParseMultipartMixed(responseBytes, out var parts, out parseError)) + return false; + + foreach (var p in parts) + { + var ct = p.contentType ?? ""; + var bodyText = ""; + try { bodyText = DecodeBytesForText(p.bodyBytes); } catch { /* ignore */ } + + if (ct.IndexOf("application/json", StringComparison.OrdinalIgnoreCase) >= 0 && LooksLikeJson(bodyText)) + { + jsonText = bodyText.Trim(); + jsonContentType = ct; + return true; + } + + if (LooksLikeJson(bodyText) && bodyText.IndexOf("\"errorCode\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + jsonText = bodyText.Trim(); + jsonContentType = ct; + return true; + } + } + + // Fallback: first JSON-like part. + foreach (var p in parts) + { + var bodyText = DecodeBytesForText(p.bodyBytes); + if (LooksLikeJson(bodyText)) + { + jsonText = bodyText.Trim(); + jsonContentType = p.contentType ?? ""; + return true; + } + } + + return false; + } + + private static bool LooksLikeBase64(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return false; + var t = s.Trim(); + if (t.Length < 16) + return false; + for (int i = 0; i < t.Length; i++) + { + char c = t[i]; + if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=') + continue; + return false; + } + return true; + } + + private static bool TryExtractBase64FromJsonText(string jsonText, IEnumerable preferredKeySubstrings, out string base64, out string debug) + { + base64 = ""; + debug = ""; + if (string.IsNullOrWhiteSpace(jsonText)) + return false; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(jsonText); + if (root == null) + return false; + + var stack = new Stack(); + stack.Push(root); + + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (kv.Value is Dictionary nd) + stack.Push(nd); + else if (kv.Value is object[] arr) + foreach (var it in arr) if (it != null) stack.Push(it); + + if (kv.Value is string s && LooksLikeBase64(s)) + { + var key = kv.Key ?? ""; + var ok = preferredKeySubstrings.Any(p => key.IndexOf(p, StringComparison.OrdinalIgnoreCase) >= 0); + if (ok) + { + base64 = s; + debug = "matchedKey=\"" + key + "\""; + return true; + } + } + } + } + else if (cur is object[] arr2) + { + foreach (var it in arr2) + if (it != null) + stack.Push(it); + } + } + } + catch (Exception ex) + { + debug = ex.Message; + } + + return false; + } + + private bool TryFetchFingerprintTemplatesViaIsapiDoc( + DeviceSession session, + string cardNo, + out List fingerprints, + out string debug) + { + fingerprints = new List(); + debug = ""; + + int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1; + bool seenError17 = false; + bool seenNotSupport = false; + + // Pro Series search endpoint for fingerprint export/readback. + var searchUrl = "/ISAPI/AccessControl/FingerPrintUpload?format=json"; + var employeeNo = cardNo.Trim(); + + // Body shapes differ by firmware; try documented/compatibility variants. + // Capability dump for this device shows these fields are expected: + // employeeNo, enableCardReader, fingerPrintID, fingerType. + var bodies = new List + { + "{ \"FingerPrintSearchCond\": { " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," + + "\"enableCardReader\": [" + readerNo + "]," + + "\"fingerPrintID\": 1," + + "\"fingerType\": \"normalFP\"" + + " } }", + + "{ \"FingerPrintSearchCond\": { " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," + + "\"cardReaderNo\": " + readerNo + "," + + "\"fingerPrintID\": 1," + + "\"fingerType\": \"normalFP\"" + + " } }", + + "{ \"FingerPrintCond\": { " + + "\"searchID\": \"1\", \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," + + "\"enableCardReader\": [" + readerNo + "]," + + "\"fingerPrintID\": 1," + + "\"fingerType\": \"normalFP\" " + + " } }", + + "{ " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," + + "\"enableCardReader\": [" + readerNo + "]," + + "\"fingerPrintID\": 1," + + "\"fingerType\": \"normalFP\" " + + " }" + }; + + foreach (var body in bodies) + { + var fpBytes = StdXmlCallBytes(session.UserId, "POST", searchUrl, body, out var sdkErr); + if (fpBytes.Length == 0) + continue; + + var decoded = DecodeBytesForText(fpBytes); + var errCode = TryExtractErrorCodeAny(decoded); + var statusSummary = ExtractIsapiStatusSummary(decoded); + debug = "FingerPrintUpload search tried; sdkErr=\"" + sdkErr + "\", " + + "status=(" + statusSummary + "), errCode=" + (errCode.HasValue ? errCode.Value.ToString() : "(null)"); + + if (statusSummary.IndexOf("subStatusCode=notSupport", StringComparison.OrdinalIgnoreCase) >= 0) + seenNotSupport = true; + + if (errCode.HasValue && errCode.Value == 17) + { + seenError17 = true; + fingerprints = new List + { + new FingerprintTemplateExportItem + { + fingerId = 1, + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = "ISAPI fingerprint errorCode=17" + } + }; + return true; + } + + // Try direct JSON parse first. + if (TryParseFingerprintItemsFromIsapiResponse(fpBytes, out fingerprints, out var parseErr) && fingerprints.Count > 0) + return true; + + // Try multipart: locate JSON part and parse it. + if (TryExtractJsonTextFromMultipart(fpBytes, out var jsonText, out var jsonCt, out var parseMultipartErr)) + { + var jsonBytes = Encoding.UTF8.GetBytes(jsonText); + if (TryParseFingerprintItemsFromIsapiResponse(jsonBytes, out fingerprints, out var parseErr2) && fingerprints.Count > 0) + return true; + } + } + + if (seenError17) + return true; + + if (seenNotSupport) + debug = string.IsNullOrWhiteSpace(debug) ? "FingerPrintUpload not supported on this firmware/path" : (debug + "; notSupport"); + + return false; + } + + private sealed class FaceLibCandidate + { + public int fdId; + public string faceLibType = ""; + } + + private static bool TryParseFaceLibCandidatesFromFdLibResponse(byte[] responseBytes, out List candidates, out string parseError) + { + candidates = new List(); + parseError = ""; + + if (responseBytes == null || responseBytes.Length == 0) + return false; + + var text = ""; + try { text = DecodeBytesForText(responseBytes); } catch { /* ignore */ } + if (!LooksLikeJson(text)) + return false; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(text); + if (root == null) + return false; + + var stack = new Stack(); + stack.Push(root); + + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + // Look for dictionaries that have both an FDID and a faceLibType. + int? fdid = null; + if (TryGetIntNullableStatic(d, "FDID", out var v1)) fdid = v1; + if (!fdid.HasValue && TryGetIntNullableStatic(d, "fdId", out var v2)) fdid = v2; + + string? faceLibType = null; + if (TryGetStringStatic(d, "faceLibType", out var t1)) faceLibType = t1; + if (faceLibType == null && TryGetStringStatic(d, "faceLib", out var t2)) faceLibType = t2; + if (faceLibType == null && TryGetStringStatic(d, "libType", out var t3)) faceLibType = t3; + + if (fdid.HasValue && !string.IsNullOrWhiteSpace(faceLibType)) + { + candidates.Add(new FaceLibCandidate + { + fdId = fdid.Value, + faceLibType = faceLibType.Trim() + }); + } + + foreach (var kv in d.Values) + { + if (kv is Dictionary nd) + stack.Push(nd); + else if (kv is object[] arr) + foreach (var it in arr) if (it != null) stack.Push(it); + } + } + else if (cur is object[] arr2) + { + foreach (var it in arr2) if (it != null) stack.Push(it); + } + } + } + catch (Exception ex) + { + parseError = ex.Message; + return false; + } + + return candidates.Count > 0; + } + + private static bool TryGetIntNullableStatic(Dictionary d, string key, out int value) + { + value = 0; + if (!d.TryGetValue(key, out var v) || v == null) + return false; + if (v is int i) { value = i; return true; } + if (v is long l) { value = (int)l; return true; } + if (v is double dd) { value = (int)dd; return true; } + if (v is string s && int.TryParse(s, out var p)) { value = p; return true; } + return false; + } + + private static bool TryGetStringStatic(Dictionary d, string key, out string value) + { + value = ""; + if (!d.TryGetValue(key, out var v) || v == null) + return false; + if (v is string s) + { + value = s; + return true; + } + value = v.ToString() ?? ""; + return !string.IsNullOrWhiteSpace(value); + } + + private bool TryFetchFaceTemplateViaIsapiDoc( + DeviceSession session, + string cardNo, + out FaceTemplateExportItem faceItem, + out string debug) + { + faceItem = new FaceTemplateExportItem + { + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = "" + }; + debug = ""; + bool seenError17 = false; + + static bool TryFindFaceRecordPointers(string json, out string fpid, out string faceUrl) + { + fpid = ""; + faceUrl = ""; + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(json); + if (root == null) + return false; + + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (kv.Value is Dictionary nd) + stack.Push(nd); + else if (kv.Value is object[] arr) + foreach (var it in arr) if (it != null) stack.Push(it); + } + + if (string.IsNullOrWhiteSpace(fpid)) + { + if (d.TryGetValue("FPID", out var fp) && fp != null) fpid = fp.ToString() ?? ""; + else if (d.TryGetValue("fPID", out var fp2) && fp2 != null) fpid = fp2.ToString() ?? ""; + } + if (string.IsNullOrWhiteSpace(faceUrl)) + { + if (d.TryGetValue("faceURL", out var fu) && fu is string s1 && !string.IsNullOrWhiteSpace(s1)) faceUrl = s1; + else if (d.TryGetValue("pictureURL", out var fu2) && fu2 is string s2 && !string.IsNullOrWhiteSpace(s2)) faceUrl = s2; + } + + if (!string.IsNullOrWhiteSpace(faceUrl)) + return true; + } + else if (cur is object[] arr2) + { + foreach (var it in arr2) + if (it != null) + stack.Push(it); + } + } + } + catch + { + return false; + } + + return !string.IsNullOrWhiteSpace(faceUrl); + } + + static string NormalizeFaceUrlToIsapiPath(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return ""; + if (url.StartsWith("/")) + return url; + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var u)) + return u.PathAndQuery; + } + return url; + } + + // Discover face picture libraries. + var fdLibBytes = StdXmlCallBytes(session.UserId, "GET", "/ISAPI/Intelligent/FDLib?format=json", null, out var fdLibSdkErr); + if (fdLibBytes.Length == 0) + { + debug = "FDLib discovery returned empty: sdkErr=\"" + fdLibSdkErr + "\""; + faceItem.error = debug; + return false; + } + + if (!TryParseFaceLibCandidatesFromFdLibResponse(fdLibBytes, out var libs, out var fdLibParseErr) || libs.Count == 0) + { + debug = "FDLib parse yielded no candidates: parseErr=\"" + fdLibParseErr + "\""; + faceItem.error = debug; + return false; + } + + var fdSearchUrl = "/ISAPI/Intelligent/FDLib/FDSearch?format=json"; + foreach (var lib in libs) + { + // Pro Series schema (12.3.2.6): root-level search fields. + // Try multiple compatible variants to avoid badJsonContent/MessageParametersLack. + var bodies = new List + { + "{ " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," + + "\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," + + "\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"," + + "\"gender\": \"any\", " + + "\"certificateType\": \"ID\" " + + " }", + + "{ " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," + + "\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," + + "\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" + + " }", + + "{ " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 10, " + + "\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," + + "\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," + + "\"employeeNo\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" + + " }" + }; + + for (int bi = 0; bi < bodies.Count; bi++) + { + var body = bodies[bi]; + var fdBytes = StdXmlCallBytes(session.UserId, "POST", fdSearchUrl, body, out var fdSdkErr); + if (fdBytes.Length == 0) + continue; + + var decoded = DecodeBytesForText(fdBytes); + var errCode = TryExtractErrorCodeAny(decoded); + debug = "FDSearch variant#" + (bi + 1) + " libType=\"" + lib.faceLibType + "\" FDID=" + lib.fdId + + " sdkErr=\"" + fdSdkErr + "\" " + ExtractIsapiStatusSummary(decoded); + if (errCode.HasValue && errCode.Value == 17) + { + seenError17 = true; + continue; + } + + if (!TryFindFaceRecordPointers(decoded, out var fpid, out var faceUrl) || string.IsNullOrWhiteSpace(faceUrl)) + continue; + + var facePath = NormalizeFaceUrlToIsapiPath(faceUrl); + if (string.IsNullOrWhiteSpace(facePath)) + continue; + + var picBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out var picSdkErr); + if (picBytes.Length > 0) + { + faceItem.present = true; + faceItem.byteLength = picBytes.Length; + faceItem.dataBase64 = Convert.ToBase64String(picBytes); + faceItem.error = "ok(faceURL, FPID=" + (string.IsNullOrWhiteSpace(fpid) ? "-" : fpid) + ")"; + return true; + } + + debug = debug + ", faceURLGetErr=\"" + picSdkErr + "\", faceURL=\"" + facePath + "\""; + } + } + + faceItem.present = false; + faceItem.byteLength = 0; + faceItem.dataBase64 = ""; + faceItem.error = seenError17 ? "missing(errorCode17)" : "face not found (FDSearch by employeeNo)"; + return false; + } + + public bool TryExportAllUsersTemplatesToIsapiFile( + string deviceId, + string? outDir, + int pageSize, + int maxUsers, + CancellationToken cancellationToken, + out string writtenJsonPath, + out string error) + { + writtenJsonPath = ""; + error = ""; + + try + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + if (pageSize <= 0) pageSize = 30; + if (maxUsers <= 0) maxUsers = 10000; + + var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim(); + Directory.CreateDirectory(dir); + + writtenJsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId).Replace(".json", "_isapi.json")); + + var faceLogPath = Path.Combine(dir, "face_templates_log.txt"); + var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt"); + + var perUserDir = Path.Combine(dir, "per_user"); + Directory.CreateDirectory(perUserDir); + + var root = new AllUsersTemplateExportPayload + { + exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + deviceId = deviceId.Trim(), + userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search" + }; + + var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken); + root.listFetchError = listErr; + + using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export face templates"); + fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export fingerprint templates"); + + _logger.Info("ExportAllTemplates ISAPI: device=" + deviceId + ", discoveredUsers=" + cardNos.Count + + ", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr)); + + foreach (var cardNo in cardNos) + { + if (cancellationToken.IsCancellationRequested) + break; + if (string.IsNullOrWhiteSpace(cardNo)) + continue; + + var userPayload = new UserTemplateExportPayload + { + exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + deviceId = deviceId.Trim(), + cardNo = cardNo.Trim(), + sdkImplementationNote = + "Pro Series ISAPI flow via NET_DVR_STDXMLConfig: capability-first, user/card search, fingerprint family (FingerPrintCfg/FingerPrintDownload/FingerPrintProgress), and face FDLib/FDSearch. Old /Face/{id}/picture and /FingerPrint/{id}/data are not used as primary." + }; + + // Card flow probe for this employeeNo/person key. + if (TrySearchCardInfoByEmployeeNoIsapi(session.UserId, cardNo.Trim(), out var cardSearchRaw, out var cardSearchErr)) + { + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + + " cardInfoSearch status=(" + ExtractIsapiStatusSummary(cardSearchRaw) + ")" + + " sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr) + + " raw_snip=\"" + ToOneLineSnippet(cardSearchRaw) + "\""); + } + else + { + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + + " cardInfoSearch failed sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr)); + } + + // Face flow: capability-first, then FDLib/FDSearch. + var fdCapRaw = ProbeCapabilityAndLog(session.UserId, "/ISAPI/Intelligent/FDLib/capabilities?format=json", "face.FDLib", faceSw); + ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw); + bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 || + fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0; + + var faceItemDoc = new FaceTemplateExportItem(); + var faceDocDebug = ""; + if (fdSearchDisabledByCap) + { + // Capability explicitly reports FDSearch unsupported on this firmware. + userPayload.face = new FaceTemplateExportItem + { + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = "FDSearch unsupported by capability (isSuportFDSearch=false); enrolled face readback not available via FDSearch on this device" + }; + } + else + { + TryFetchFaceTemplateViaIsapiDoc(session, cardNo, out faceItemDoc, out faceDocDebug); + userPayload.face = faceItemDoc; + } + if (!userPayload.face.present) + { + userPayload.face = new FaceTemplateExportItem + { + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = string.IsNullOrWhiteSpace(faceDocDebug) + ? "Face export/readback not confirmed by Pro Series flow on this device. Capture/add may be supported; enrolled readback returned no data." + : faceDocDebug + }; + if (fdSearchDisabledByCap) + userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false"; + } + + if (!userPayload.face.present) + { + // Make missing template errors explicit in the face log. + var err = userPayload.face.error ?? ""; + if (err.IndexOf("17", StringComparison.OrdinalIgnoreCase) >= 0) + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " missing(errorCode17)=" + err); + else + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " faceError=" + err); + } + + // Fingerprint flow: capability-first, then FingerPrintDownload family. + ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/FingerPrintCfg/capabilities?format=json", "fingerprint.FingerPrintCfg", fpSw); + var fpItemsDoc = new List(); + var fpDocDebug = ""; + bool fpDocOk = TryFetchFingerprintTemplatesViaRemoteConfig(session, cardNo, out fpItemsDoc, out fpDocDebug); + if (!fpDocOk || fpItemsDoc.Count == 0) + { + // Fallback to Pro-Series ISAPI fingerprint search/upload endpoint family. + fpDocOk = TryFetchFingerprintTemplatesViaIsapiDoc(session, cardNo, out fpItemsDoc, out fpDocDebug); + } + if (fpDocOk && fpItemsDoc.Count > 0) + { + userPayload.fingerprints = fpItemsDoc; + } + else + { + userPayload.fingerprints = new List + { + new FingerprintTemplateExportItem + { + fingerId = 1, + attempted = true, + present = false, + byteLength = 0, + dataBase64 = "", + error = string.IsNullOrWhiteSpace(fpDocDebug) + ? "Fingerprint export/readback not confirmed by Pro Series ISAPI flow on this device. Management APIs may be supported." + : fpDocDebug + } + }; + } + + foreach (var fp in userPayload.fingerprints) + { + fpSw.WriteLine(DateTime.UtcNow.ToString("o") + + " device=" + deviceId + + " cardNo=" + cardNo + + " fingerId=" + fp.fingerId + + " present=" + fp.present + + " len=" + fp.byteLength + + " error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error)); + } + + root.users.Add(userPayload); + + // Per-user JSON file as requested. + var perUserPath = Path.Combine(perUserDir, "user_" + cardNo.Trim() + "_templates.json"); + var perJson = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(userPayload); + File.WriteAllText(perUserPath, perJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root); + File.WriteAllText(writtenJsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + _logger.Info("ExportAllTemplates ISAPI: wrote combined json path=" + writtenJsonPath + ", users=" + root.users.Count); + return true; + } + catch (OperationCanceledException) + { + error = "cancelled"; + return false; + } + catch (Exception ex) + { + error = ex.Message; + _logger.Error("TryExportAllUsersTemplatesToIsapiFile failed", ex); + return false; + } + } + + private static string ParseStdXmlResponseStatus(string responseJson) + { + if (string.IsNullOrWhiteSpace(responseJson)) + return "(empty)"; + + try + { + var ser = new JavaScriptSerializer(); + object? obj = ser.DeserializeObject(responseJson); + if (obj == null) + return "(unparsed)"; + + if (TryFindString(obj, new[] { "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var s)) + return s; + + if (TryFindString(obj, new[] { "ResponseStatus" }, out var s2)) + return s2; + + if (TryFindInt(obj, new[] { "statusCode", "responseStatusCode" }, out var i)) + return "code=" + i; + + return "(parsed:no known status keys)"; + } + catch + { + var m = System.Text.RegularExpressions.Regex.Match( + responseJson, + "\"responseStatus(Strg|Str|String)\"\\s*:\\s*\"(?[^\"]*)\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) + return m.Groups["v"].Value; + return "(unparsed)"; + } + } + + private static bool TryFindString(object obj, IEnumerable keys, out string value) + { + value = ""; + var stack = new Stack(); + stack.Push(obj); + var keySet = new HashSet(keys, StringComparer.OrdinalIgnoreCase); + + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (keySet.Contains(kv.Key) && kv.Value is string sv) + { + value = sv; + return true; + } + + if (kv.Value != null) + stack.Push(kv.Value); + } + } + else if (cur is object[] arr) + { + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + + return false; + } + + private static bool TryFindInt(object obj, IEnumerable keys, out int value) + { + value = 0; + var stack = new Stack(); + stack.Push(obj); + var keySet = new HashSet(keys, StringComparer.OrdinalIgnoreCase); + + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + foreach (var kv in d) + { + if (keySet.Contains(kv.Key)) + { + if (kv.Value is int i) + { + value = i; + return true; + } + if (kv.Value is long l) + { + value = (int)l; + return true; + } + if (kv.Value is double dd) + { + value = (int)dd; + return true; + } + if (kv.Value is string s && int.TryParse(s, out var p)) + { + value = p; + return true; + } + } + + if (kv.Value != null) + stack.Push(kv.Value); + } + } + else if (cur is object[] arr) + { + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + + return false; + } + + private static List> ExtractAcsEventInfoList(string responseJson) + { + var result = new List>(); + if (string.IsNullOrWhiteSpace(responseJson)) + return result; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(responseJson); + if (root == null) + return result; + + // Typical structure: { "AcsEvent": { "InfoList": [ ... ] } } + if (root is Dictionary d && d.TryGetValue("AcsEvent", out var acsObj) && acsObj is Dictionary acsDict) + { + if (acsDict.TryGetValue("InfoList", out var infoListObj) && infoListObj is object[] arr) + { + foreach (var it in arr) + { + if (it is Dictionary itemDict) + result.Add(itemDict); + } + + if (result.Count > 0) + return result; + } + } + + // Recursive best-effort fallback. + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0 && result.Count == 0) + { + var cur = stack.Pop(); + if (cur is Dictionary cd) + { + foreach (var kv in cd) + { + if (string.Equals(kv.Key, "InfoList", StringComparison.OrdinalIgnoreCase) && kv.Value is object[] arr) + { + foreach (var it in arr) + { + if (it is Dictionary itemDict) + result.Add(itemDict); + } + break; + } + + if (kv.Value != null) + stack.Push(kv.Value); + } + } + else if (cur is object[] arr) + { + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + } + catch + { + // Best-effort only; caller logs raw response. + } + + return result; + } + + private static bool TryBuildAttendanceFromStdAcsInfo( + HikvisionAttendanceWindowsService.DeviceConfig device, + Dictionary info, + uint rawMajor, + uint rawMinor, + string eventName, + string eventType, + bool isSuccessByMinorRule, + out AttendanceEvent attendanceEvent) + { + attendanceEvent = null!; + + // Required-ish fields mentioned in your guide excerpt: + // - employeeNoString + // - currentVerifyMode + // - attendanceStatus + // - statusValue + + string? empStr = TryGetString(info, "employeeNoString", "employeeNo", "employeeNoStr"); + int? employeeNo = null; + if (!string.IsNullOrWhiteSpace(empStr) && int.TryParse(empStr.Trim(), out var parsedEmp)) + employeeNo = parsedEmp; + + string? userIdentifier = string.IsNullOrWhiteSpace(empStr) ? null : empStr.Trim(); + + // Optional fields: card/door/reader vary by device & config. + string? cardNo = TryGetString(info, "cardNoString", "cardNo"); + int doorNo = TryGetInt(info, "doorNo", 0); + int readerNo = TryGetInt(info, "readerNo", 0); + + byte currentVerifyMode = 0; + var verifyMode = TryGetIntNullable(info, "currentVerifyMode"); + if (verifyMode.HasValue) + currentVerifyMode = (byte)Math.Max(0, Math.Min(255, verifyMode.Value)); + + // Timestamp: best effort across common key names containing "Time". + DateTime ts; + if (!TryExtractStdAcsDateTime(info, out var parsedTs)) + ts = DateTime.UtcNow; + else + ts = parsedTs; + + // Infer method from currentVerifyMode (and minor-driven rules if any). + string method = AcsAttendanceParser.InferMethodFromAcsDetail( + rawMinor, + byCardReaderKind: 0, + byCurrentVerifyMode: currentVerifyMode, + eventNameFallback: eventName); + + // Use existing success inference rules first; optionally refine using statusValue. + bool isSuccess = isSuccessByMinorRule; + if (!isSuccess) + { + var statusValue = TryGetIntNullable(info, "statusValue"); + if (statusValue.HasValue) + isSuccess = statusValue.Value != 0; + } + + attendanceEvent = new AttendanceEvent( + device.DeviceId, + device.Ip ?? "", + ts, + employeeNo, + userIdentifier, + string.IsNullOrWhiteSpace(cardNo) ? null : cardNo, + doorNo, + readerNo, + method, + eventName, + eventType, + "Historical", + isSuccess, + rawMajor, + rawMinor, + historySerialNo: 0); + + return true; + } + + private static string? TryGetString(Dictionary d, params string[] keys) + { + foreach (var k in keys) + { + if (!d.TryGetValue(k, out var v) || v == null) + continue; + return v is string s ? s : v.ToString(); + } + return null; + } + + private static int TryGetInt(Dictionary d, string key, int defaultValue) + { + if (!d.TryGetValue(key, out var v) || v == null) + return defaultValue; + if (v is int i) return i; + if (v is long l) return (int)l; + if (v is double dd) return (int)dd; + if (v is string s && int.TryParse(s, out var p)) return p; + return defaultValue; + } + + private static int? TryGetIntNullable(Dictionary d, string key) + { + if (!d.TryGetValue(key, out var v) || v == null) + return null; + if (v is int i) return i; + if (v is long l) return (int)l; + if (v is double dd) return (int)dd; + if (v is string s && int.TryParse(s, out var p)) return p; + return null; + } + + private static bool TryExtractStdAcsDateTime(Dictionary info, out DateTime dt) + { + dt = default; + + string[] candidateKeys = + { + "statusTime", + "verifyTime", + "attendanceTime", + "eventTime", + "time", + "statusTimeString", + "verifyTimeString" + }; + + foreach (var key in candidateKeys) + { + if (info.TryGetValue(key, out var v) && v != null) + { + if (TryParseStdAcsDateTimeValue(v, out dt)) + return true; + } + } + + foreach (var kv in info) + { + if (kv.Key != null && kv.Key.IndexOf("time", StringComparison.OrdinalIgnoreCase) >= 0) + { + if (TryParseStdAcsDateTimeValue(kv.Value, out dt)) + return true; + } + } + + return false; + } + + private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt) + { + dt = default; + if (value == null) + return false; + + if (value is DateTime d) + { + dt = d; + return true; + } + + if (value is long l) + { + try + { + // Heuristic: >10 digits => milliseconds. + if (l > 10_000_000_000L) + dt = DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime; + else + dt = DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime; + return true; + } + catch + { + return false; + } + } + + if (value is int i) + return TryParseStdAcsDateTimeValue((long)i, out dt); + + string s = value is string ss ? ss : value.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(s)) + return false; + + s = s.Trim(); + + string[] formats = + { + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", + "yyyy-MM-dd'T'HH:mm:sszzz", + "yyyy-MM-dd'T'HH:mm:ss.FFFzzz", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd" + }; + + if (DateTime.TryParseExact( + s, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out dt)) + return true; + + if (DateTime.TryParse( + s, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dt)) + return true; + + return false; + } + + private static string TruncateForLog(string s, int maxChars) + { + if (string.IsNullOrEmpty(s)) + return s ?? ""; + if (s.Length <= maxChars) + return s; + return s.Substring(0, maxChars) + "...[truncated " + (s.Length - maxChars) + " chars]"; + } + + private static CHCNetSDK.NET_DVR_TIME ToDvrTime(DateTime local) + { + return new CHCNetSDK.NET_DVR_TIME + { + dwYear = local.Year, + dwMonth = local.Month, + dwDay = local.Day, + dwHour = local.Hour, + dwMinute = local.Minute, + dwSecond = local.Second + }; + } + + private static void PrepareAcsEventCfgPointer(IntPtr cfgPtr, int cfgSize) + { + var cfg = new CHCNetSDK.NET_DVR_ACS_EVENT_CFG(); + cfg.sNetUser = new byte[CHCNetSDK.MAX_NAMELEN]; + cfg.struRemoteHostAddr.Init(); + var d = new CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL(); + d.dwSize = (uint)Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL)); + d.byCardNo = new byte[CHCNetSDK.ACS_CARD_NO_LEN]; + d.byMACAddr = new byte[CHCNetSDK.MACADDR_LEN]; + d.byRe2 = new byte[2]; + d.byEmployeeNo = new byte[CHCNetSDK.NET_SDK_EMPLOYEE_NO_LEN]; + d.byRes = new byte[64]; + cfg.struAcsEventInfo = d; + cfg.byRes = new byte[61]; + cfg.dwSize = (uint)cfgSize; + cfg.dwPicDataLen = 0; + cfg.pPicData = IntPtr.Zero; + Marshal.StructureToPtr(cfg, cfgPtr, false); + } + + private bool ControlDoor(string deviceId, uint action, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(deviceId)) + { + error = "deviceId is required"; + return false; + } + + var session = FindSession(deviceId); + if (session == null) + { + error = "device not logged in: " + deviceId; + return false; + } + + int doorIndex = session.Device.GatewayDoorIndex > 0 ? session.Device.GatewayDoorIndex : 1; + bool ok = EventByDeploy.CHCNetSDK.NET_DVR_ControlGateway(session.UserId, doorIndex, action); + if (!ok) + { + error = "NET_DVR_ControlGateway failed, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action + ", " + BuildSdkError("NET_DVR_ControlGateway"); + _logger.Error(error); + return false; + } + + _logger.Info("NET_DVR_ControlGateway succeeded, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action); + return true; + } + + private void LogConfiguredTerminalProfile(HikvisionAttendanceWindowsService.DeviceConfig device) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(device.Model)) + parts.Add("model=" + device.Model.Trim()); + if (!string.IsNullOrWhiteSpace(device.FirmwareVersion)) + parts.Add("firmware=" + device.FirmwareVersion.Trim()); + if (!string.IsNullOrWhiteSpace(device.SerialNumber)) + parts.Add("serial=" + device.SerialNumber.Trim()); + if (!string.IsNullOrWhiteSpace(device.SubnetMask)) + parts.Add("mask=" + device.SubnetMask.Trim()); + if (!string.IsNullOrWhiteSpace(device.DefaultGateway)) + parts.Add("gateway=" + device.DefaultGateway.Trim()); + + if (parts.Count > 0) + _logger.Info("Terminal profile (config): " + string.Join(", ", parts) + "."); + } + + private static string FormatSdkSerial(byte[]? bytes) + { + if (bytes == null || bytes.Length == 0) + return ""; + try + { + return Encoding.ASCII.GetString(bytes).TrimEnd('\0').Trim(); + } + catch + { + return ""; + } + } + + private DeviceSession FindSession(string deviceId) + { + var key = DeviceIdentity.CanonicalLookupKey(deviceId); + if (key.Length == 0) + return null; + + foreach (var s in _sessions) + { + if (DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key) + return s; + } + return null; + } + + private static void CopyUtf8(string value, byte[] target) + { + if (target == null || target.Length == 0 || string.IsNullOrEmpty(value)) + return; + + var src = Encoding.UTF8.GetBytes(value); + int n = Math.Min(src.Length, target.Length); + Buffer.BlockCopy(src, 0, target, 0, n); + } + + // SDK callback: do not block (only enqueue). Signature must match Common.CHCNetSDK.MSGCallBack (same module as Init). + private void AlarmCallback(int lCommand, ref Common.CHCNetSDK.NET_DVR_ALARMER pAlarmer, IntPtr pAlarmInfo, uint dwBufLen, IntPtr pUser) + { + try + { + if (lCommand != Common.CHCNetSDK.COMM_ALARM_ACS) + return; + + var acsAlarm = Marshal.PtrToStructure(pAlarmInfo); + + var eventName = MapAcsEventName(acsAlarm.dwMajor, acsAlarm.dwMinor); + if (string.IsNullOrWhiteSpace(eventName)) + eventName = "MAJOR_" + acsAlarm.dwMajor + "_MINOR_" + acsAlarm.dwMinor; + + uint rawMajor = acsAlarm.dwMajor; + uint rawMinor = acsAlarm.dwMinor; + + var info = acsAlarm.struAcsEventInfo; + uint empNo = info.dwEmployeeNo; + string cardNo = DecodeCardNo(info.byCardNo); + + bool hasEmployee = empNo != 0; + bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; + if (!hasEmployee && !hasCard) + return; + + DateTime ts = FromSdkTime(acsAlarm.struTime); + bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(rawMajor, rawMinor, eventName); + string method = AcsAttendanceParser.InferMethodFromAcsDetail(rawMinor, info.byCardReaderKind, 0, eventName); + + string deviceId = "unknown"; + string deviceIp = pAlarmer.sDeviceIP ?? "unknown"; + foreach (var s in _sessions) + { + if (s.UserId == pAlarmer.lUserID) + { + deviceId = s.Device.DeviceId; + deviceIp = s.Device.Ip ?? deviceIp; + break; + } + } + + string eventType = AcsAttendanceParser.MapMajorCategory(rawMajor) + "/" + rawMinor.ToString("X"); + + var attendanceEvent = new AttendanceEvent( + deviceId, + deviceIp, + ts, + hasEmployee ? (int?)((int)empNo) : null, + null, + hasCard ? cardNo : null, + (int)info.dwDoorNo, + (int)info.dwCardReaderNo, + method, + eventName, + eventType, + "Live", + isSuccess, + rawMajor, + rawMinor, + 0); + + EnqueueAttendance(attendanceEvent, "LIVE ACS event"); + } + catch (Exception ex) + { + _logger.Error("AlarmCallback parse error", ex); + } + } + + private string MapAcsEventName(uint dwMajor, uint dwMinor) + { + var logInfo = new EventByDeploy.CHCNetSDK.NET_DVR_LOG_V30(); + logInfo.dwMajorType = dwMajor; + logInfo.dwMinorType = dwMinor; + + char[] csTmp = new char[256]; + + if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_ALARM) + TypeMap.AlarmMinorTypeMap(logInfo, csTmp); + else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_OPERATION) + TypeMap.OperationMinorTypeMap(logInfo, csTmp); + else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_EXCEPTION) + TypeMap.ExceptionMinorTypeMap(logInfo, csTmp); + else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_EVENT) + TypeMap.EventMinorTypeMap(logInfo, csTmp); + + return new string(csTmp).TrimEnd('\0').Trim(); + } + + private bool TryBuildAttendanceFromAcsCfg( + DeviceSession session, + ref CHCNetSDK.NET_DVR_ACS_EVENT_CFG cfg, + out AttendanceEvent ev) + { + ev = null!; + + var detail = cfg.struAcsEventInfo; + string eventName = MapAcsEventName(cfg.dwMajor, cfg.dwMinor); + if (string.IsNullOrWhiteSpace(eventName)) + eventName = "MAJOR_" + cfg.dwMajor + "_MINOR_" + cfg.dwMinor; + + string userIdStr = DecodeEmployeeNo(detail.byEmployeeNo); + uint empNum = detail.dwEmployeeNo; + string cardNo = DecodeCardNo(detail.byCardNo); + + bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; + bool hasEmpNum = empNum != 0; + bool hasEmpStr = !string.IsNullOrWhiteSpace(userIdStr); + if (!hasCard && !hasEmpNum && !hasEmpStr) + return false; + + int? employeeNo = null; + if (hasEmpNum) + employeeNo = (int)empNum; + else if (hasEmpStr && int.TryParse(userIdStr, out var parsed)) + employeeNo = parsed; + + DateTime ts = FromSdkTime(cfg.struTime); + bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(cfg.dwMajor, cfg.dwMinor, eventName); + string method = AcsAttendanceParser.InferMethodFromAcsDetail( + cfg.dwMinor, + detail.byCardReaderKind, + detail.byCurrentVerifyMode, + eventName); + + string eventType = AcsAttendanceParser.MapMajorCategory(cfg.dwMajor) + "/" + cfg.dwMinor.ToString("X"); + + ev = new AttendanceEvent( + session.Device.DeviceId, + session.Device.Ip ?? "", + ts, + employeeNo, + hasEmpStr ? userIdStr : null, + hasCard ? cardNo : null, + (int)detail.dwDoorNo, + (int)detail.dwCardReaderNo, + method, + eventName, + eventType, + "Historical", + isSuccess, + cfg.dwMajor, + cfg.dwMinor, + detail.dwSerialNo); + + return true; + } + + private void EnqueueAttendance(AttendanceEvent attendanceEvent, string logContext) + { + if (_config.AutoDoorControlOnSuccess && attendanceEvent.IsSuccess && attendanceEvent.Source == "Live") + { + TriggerAutoDoor(attendanceEvent.DeviceId); + } + + if (Volatile.Read(ref _queueSize) >= _queueMax) + { + _logger.Warn(logContext + ": queue full, dropping event for device " + attendanceEvent.DeviceId); + return; + } + + if (string.Equals(attendanceEvent.Source, "Historical", StringComparison.OrdinalIgnoreCase)) + { + var key = attendanceEvent.DedupeKey; + if (!_dedupeKeys.TryAdd(key, 0)) + { + _logger.Info("Dedupe skip (historical): " + key); + return; + } + + if (_dedupeKeys.Count > DedupeMaxEntries) + { + _dedupeKeys.Clear(); + _logger.Warn("Dedupe cache cleared (size limit)."); + } + } + + _queue.Enqueue(attendanceEvent); + Interlocked.Increment(ref _queueSize); + _queueSignal.Release(); + + _logger.Info(logContext + ": device=" + attendanceEvent.DeviceId + ", emp=" + attendanceEvent.EmployeeNo + + ", userId=" + attendanceEvent.UserIdentifier + ", card=" + attendanceEvent.CardNo + + ", door=" + attendanceEvent.DoorNo + ", reader=" + attendanceEvent.ReaderNo + + ", method=" + attendanceEvent.AttendanceMethod + ", success=" + attendanceEvent.IsSuccess + + ", major/minor=" + attendanceEvent.RawMajor + "/" + attendanceEvent.RawMinor); + } + + private static string DecodeEmployeeNo(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + return ""; + try + { + return Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim(); + } + catch + { + return ""; + } + } + + private static DateTime FromSdkTime(EventByDeploy.CHCNetSDK.NET_DVR_TIME t) + { + // Sdk structs use int; guard against 0/invalid timestamps. + if (t.dwYear <= 1900) + { + return DateTime.UtcNow; + } + + return new DateTime(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond); + } + + private static string DecodeCardNo(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + return ""; + try + { + return Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim(); + } + catch + { + return ""; + } + } + + private void QueueWriterLoop(CancellationToken token) + { + StreamWriter sw = null; + try + { + // Append continuously; flush per event to keep “real-time” feel. + sw = new StreamWriter(new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)); + sw.AutoFlush = true; + + while (!token.IsCancellationRequested) + { + _queueSignal.Wait(token); + + AttendanceEvent ev; + while (_queue.TryDequeue(out ev)) + { + Interlocked.Decrement(ref _queueSize); + sw.WriteLine(ToCsvLine(ev)); + AppendAttendanceToTextFileSafely(ev); + WriteAttendanceToDatabase(ev); + } + } + } + catch (OperationCanceledException) + { + // expected + } + catch (Exception ex) + { + _logger.Error("QueueWriterLoop failed", ex); + } + finally + { + try { if (sw != null) sw.Dispose(); } catch { /* ignore */ } + } + } + + private void ExportLoop(CancellationToken token) + { + // Simple exporter placeholder: periodically copies the latest CSV for HR/payroll integration. + while (!token.IsCancellationRequested) + { + try + { + token.WaitHandle.WaitOne(TimeSpan.FromMinutes(1)); + if (token.IsCancellationRequested) + break; + + lock (_csvWriteLock) + { + if (File.Exists(_csvPath)) + { + File.Copy(_csvPath, _exportPath, overwrite: true); + File.Copy(_csvPath, _hrExportPath, overwrite: true); + _logger.Info("ExportLoop: attendance_events.csv copied to attendance_export.csv and HR export path."); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.Error("ExportLoop failed", ex); + } + } + } + + private static string ToCsvLine(AttendanceEvent ev) + { + string Q(string? s) + { + if (s == null) return "\"\""; + s = s.Replace("\"", "\"\""); + return "\"" + s + "\""; + } + + return string.Join(",", + Q(ev.DeviceId), + Q(ev.DeviceIp), + Q(ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")), + ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : "", + Q(ev.UserIdentifier), + Q(ev.CardNo), + ev.DoorNo.ToString(), + ev.ReaderNo.ToString(), + Q(ev.AttendanceMethod), + Q(ev.EventName), + Q(ev.EventType), + Q(ev.Source), + ev.IsSuccess ? "1" : "0", + ev.RawMajor.ToString(), + ev.RawMinor.ToString()); + } + + public void Dispose() + { + try { if (_cts != null) _cts.Cancel(); } catch { /* ignore */ } + } + + /// Human-readable one-line record. Re-enable SQL path via EnableDatabasePersistence + SqlConnectionString. + private void AppendAttendanceToTextFileSafely(AttendanceEvent ev) + { + try + { + var line = FormatAttendanceTextLine(ev); + lock (_attendanceTextLock) + { + File.AppendAllText(_attendanceTextPath, line + Environment.NewLine, Encoding.UTF8); + } + + _logger.Info("Attendance text file: write OK path=" + _attendanceTextPath + " Source=" + ev.Source); + } + catch (Exception ex) + { + _logger.Error("Attendance text file: write FAILED path=" + _attendanceTextPath + " Source=" + ev.Source, ex); + } + } + + private static string FormatAttendanceTextLine(AttendanceEvent ev) + { + string emp = ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : ""; + return "[" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + "] " + + "Device=" + TxtToken(ev.DeviceId) + + " IP=" + TxtToken(ev.DeviceIp) + + " EmployeeId=" + TxtToken(emp) + + " UserIdentifier=" + TxtToken(ev.UserIdentifier) + + " CardNo=" + TxtToken(ev.CardNo) + + " Method=" + TxtToken(ev.AttendanceMethod) + + " Event=" + TxtToken(ev.EventName) + + " Door=" + ev.DoorNo + + " Reader=" + ev.ReaderNo + + " Success=" + (ev.IsSuccess ? "true" : "false") + + " Major=" + ev.RawMajor + + " Minor=" + ev.RawMinor + + " Source=" + TxtToken(ev.Source); + } + + private static string TxtToken(string? value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + if (value.IndexOf(' ') >= 0 || value.IndexOf('=') >= 0) + return "\"" + value.Replace("\"", "\"\"") + "\""; + + return value; + } + + private void WriteAttendanceToDatabase(AttendanceEvent ev) + { + if (!_config.EnableDatabasePersistence) + return; + + if (string.IsNullOrWhiteSpace(_config.SqlConnectionString)) + return; + + try + { + using (var conn = new SqlConnection(_config.SqlConnectionString)) + { + conn.Open(); + // EventType column = SDK descriptive name (same as pre-change behavior). Add optional columns in DB as needed. + var sql = "INSERT INTO " + _config.AttendanceTableName + " " + + "(DeviceId, DeviceIp, EmployeeId, UserIdentifier, CardNo, EventType, AttendanceMethod, EventTimestamp, DoorNo, ReaderNo, IsSuccess, RawMajor, RawMinor, EventSource) " + + "VALUES (@DeviceId,@DeviceIp,@EmployeeId,@UserIdentifier,@CardNo,@EventType,@AttendanceMethod,@EventTimestamp,@DoorNo,@ReaderNo,@IsSuccess,@RawMajor,@RawMinor,@EventSource)"; + using (var cmd = new SqlCommand(sql, conn)) + { + cmd.Parameters.AddWithValue("@DeviceId", (object)ev.DeviceId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@DeviceIp", (object)ev.DeviceIp ?? DBNull.Value); + cmd.Parameters.AddWithValue("@EmployeeId", (object)ev.EmployeeNo ?? DBNull.Value); + cmd.Parameters.AddWithValue("@UserIdentifier", (object)ev.UserIdentifier ?? DBNull.Value); + cmd.Parameters.AddWithValue("@CardNo", (object)ev.CardNo ?? DBNull.Value); + cmd.Parameters.AddWithValue("@EventType", (object)ev.EventName ?? DBNull.Value); + cmd.Parameters.AddWithValue("@AttendanceMethod", (object)ev.AttendanceMethod ?? DBNull.Value); + cmd.Parameters.AddWithValue("@EventTimestamp", ev.Timestamp); + cmd.Parameters.AddWithValue("@DoorNo", ev.DoorNo); + cmd.Parameters.AddWithValue("@ReaderNo", ev.ReaderNo); + cmd.Parameters.AddWithValue("@IsSuccess", ev.IsSuccess); + cmd.Parameters.AddWithValue("@RawMajor", ev.RawMajor); + cmd.Parameters.AddWithValue("@RawMinor", ev.RawMinor); + cmd.Parameters.AddWithValue("@EventSource", (object)ev.Source ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + } + } + catch (Exception ex) + { + _logger.Error("WriteAttendanceToDatabase failed (extend table: UserIdentifier NVARCHAR, ReaderNo INT, EventSource NVARCHAR)", ex); + } + } + + private void TriggerAutoDoor(string deviceId) + { + Task.Run(() => + { + string error; + if (!ControlDoor(deviceId, 1, out error)) + return; + + _logger.Info("AUTO_DOOR: opened gateway after successful access, deviceId=" + deviceId + + ", delayCloseSec=" + _config.AutoDoorCloseDelaySeconds); + + if (_config.AutoDoorCloseDelaySeconds <= 0) + return; + + try + { + Thread.Sleep(TimeSpan.FromSeconds(_config.AutoDoorCloseDelaySeconds)); + ControlDoor(deviceId, 0, out error); + _logger.Info("AUTO_DOOR: close after delay, deviceId=" + deviceId); + } + catch (Exception ex) + { + _logger.Error("TriggerAutoDoor failed", ex); + } + }); + } + + private string BuildSdkError(string operation) + { + try + { + uint err = Common.CHCNetSDK.NET_DVR_GetLastError(); + return operation + " errorCode=" + err; + } + catch + { + return operation + " errorCode=unknown"; + } + } + + private sealed class DeviceSession + { + public DeviceSession(HikvisionAttendanceWindowsService.DeviceConfig device, int userId, int alarmHandle) + { + Device = device; + UserId = userId; + AlarmHandle = alarmHandle; + } + + public HikvisionAttendanceWindowsService.DeviceConfig Device { get; private set; } + public int UserId { get; set; } + public int AlarmHandle { get; set; } + } + + private sealed class AttendanceEvent + { + public AttendanceEvent( + string deviceId, + string deviceIp, + DateTime timestamp, + int? employeeNo, + string? userIdentifier, + string? cardNo, + int doorNo, + int readerNo, + string attendanceMethod, + string eventName, + string eventType, + string source, + bool isSuccess, + uint rawMajor, + uint rawMinor, + uint historySerialNo) + { + DeviceId = deviceId; + DeviceIp = deviceIp; + Timestamp = timestamp; + EmployeeNo = employeeNo; + UserIdentifier = userIdentifier; + CardNo = cardNo; + DoorNo = doorNo; + ReaderNo = readerNo; + AttendanceMethod = attendanceMethod; + EventName = eventName; + EventType = eventType; + Source = source; + IsSuccess = isSuccess; + RawMajor = rawMajor; + RawMinor = rawMinor; + HistorySerialNo = historySerialNo; + } + + public string DeviceId { get; } + public string DeviceIp { get; } + public DateTime Timestamp { get; } + public int? EmployeeNo { get; } + public string? UserIdentifier { get; } + public string? CardNo { get; } + public int DoorNo { get; } + public int ReaderNo { get; } + public string AttendanceMethod { get; } + public string EventName { get; } + public string EventType { get; } + public string Source { get; } + public bool IsSuccess { get; } + public uint RawMajor { get; } + public uint RawMinor { get; } + public uint HistorySerialNo { get; } + + public string DedupeKey => + Source + "|" + DeviceId + "|" + (HistorySerialNo != 0 + ? HistorySerialNo.ToString() + : Timestamp.ToString("yyyyMMddHHmmss") + "|" + RawMajor + "|" + RawMinor) + "|" + + (EmployeeNo?.ToString() ?? "") + "|" + (UserIdentifier ?? "") + "|" + (CardNo ?? "") + "|" + + DoorNo + "|" + ReaderNo; + } +} + diff --git a/HikvisionAttendanceService.csproj b/HikvisionAttendanceService.csproj new file mode 100644 index 0000000..5005a61 --- /dev/null +++ b/HikvisionAttendanceService.csproj @@ -0,0 +1,43 @@ + + + + WinExe + net48 + enable + latest + enable + dotnet-HikvisionAttendanceService-be7e0e28-c864-4273-ade4-819e35c57527 + x86 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HikvisionAttendanceService.sln b/HikvisionAttendanceService.sln new file mode 100644 index 0000000..01844a2 --- /dev/null +++ b/HikvisionAttendanceService.sln @@ -0,0 +1,51 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Service", "Service", "{226567F5-DD15-7888-5BF1-C4407778305A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HikvisionAttendanceService", "HikvisionAttendanceService.csproj", "{B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Config", "Config", "{1AD244E0-696C-4A2A-31B1-1832C4FA60E4}" + ProjectSection(SolutionItems) = preProject + appsettings.json = appsettings.json + serviceconfig.json = serviceconfig.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Interop", "Interop", "{FA46DF89-6585-3869-AC2D-4F5A2BEB4D3F}" + ProjectSection(SolutionItems) = preProject + Interop\AppsDemoStubs.cs = Interop\AppsDemoStubs.cs + Interop\HikvisionTemplateInterop.cs = Interop\HikvisionTemplateInterop.cs + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|x64.ActiveCfg = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|x64.Build.0 = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|x86.ActiveCfg = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Debug|x86.Build.0 = Debug|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|Any CPU.Build.0 = Release|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|x64.ActiveCfg = Release|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|x64.Build.0 = Release|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|x86.ActiveCfg = Release|Any CPU + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B85FFCC7-E3DA-46AC-BE26-DEA0F65BDB91} = {226567F5-DD15-7888-5BF1-C4407778305A} + EndGlobalSection +EndGlobal diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs new file mode 100644 index 0000000..cc57158 --- /dev/null +++ b/HikvisionAttendanceWindowsService.cs @@ -0,0 +1,367 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Threading; +using System.Threading.Tasks; +using System.ServiceProcess; + +namespace HikvisionAttendanceService; + +public sealed class HikvisionAttendanceWindowsService : ServiceBase +{ + private CancellationTokenSource? _cts; + private Task? _mainTask; + private HikvisionAttendanceManager? _manager; + + public HikvisionAttendanceWindowsService() + { + ServiceName = "HikvisionAttendanceService"; + CanStop = true; + CanPauseAndContinue = false; + AutoLog = false; // we use our own file logger + } + + protected override void OnStart(string[] args) + { + _cts = new CancellationTokenSource(); + Environment.CurrentDirectory = AppContext.BaseDirectory; + + var baseDir = AppContext.BaseDirectory; + var configPath = Path.Combine(baseDir, "serviceconfig.json"); + if (!File.Exists(configPath)) + { + // Fallback to appsettings.json (developer convenience). + configPath = Path.Combine(baseDir, "appsettings.json"); + } + + var config = HikvisionServiceConfig.Load(configPath); + var logger = new FileLogger(config.LogDirectory); + + _manager = new HikvisionAttendanceManager(config, logger); + _mainTask = _manager.RunAsync(_cts.Token); + } + + protected override void OnStop() + { + if (_cts is null) + { + return; + } + + try + { + _cts.Cancel(); + } + catch + { + // ignore + } + + if (_mainTask is not null) + { + try { _mainTask.Wait(TimeSpan.FromSeconds(20)); } catch { /* ignore */ } + } + + _manager?.Dispose(); + _manager = null; + } + + [DataContract] + internal sealed class HikvisionServiceConfig + { + [DataMember] + public string LogDirectory { get; set; } = @"C:\SdkLog"; + + [DataMember] + public string SqlConnectionString { get; set; } = ""; + + /// When true and SqlConnectionString is set, attendance rows are INSERTed. Default false until schema is finalized. + [DataMember] + public bool EnableDatabasePersistence { get; set; } + + /// Plain-text attendance log. Empty = LogDirectory\attendance_records.txt + [DataMember] + public string AttendanceTextFilePath { get; set; } = ""; + + [DataMember] + public string AttendanceTableName { get; set; } = "dbo.HikvisionAttendanceEvents"; + + [DataMember] + public string HrExportPath { get; set; } = ""; + + [DataMember] + public bool AutoDoorControlOnSuccess { get; set; } = true; + + [DataMember] + public int AutoDoorCloseDelaySeconds { get; set; } = 3; + + [DataMember] + public List Devices { get; set; } = new List(); + + /// 0 = all majors (per GetACSEvent demo). + [DataMember] + public uint AcsHistoryMajor { get; set; } + + /// 0 = all minors. + [DataMember] + public uint AcsHistoryMinor { get; set; } + + /// 0 = disabled. Otherwise interval in minutes for NET_DVR_GET_ACS_EVENT sync. + [DataMember] + public int HistoricalFetchIntervalMinutes { get; set; } + + [DataMember] + public int HistoricalFetchLookbackMinutes { get; set; } = 1440; + + public static HikvisionServiceConfig Load(string configPath) + { + // Minimal JSON config loader (no external packages). + try + { + if (!File.Exists(configPath)) + { + return new HikvisionServiceConfig + { + LogDirectory = @"C:\SdkLog", + SqlConnectionString = "", + EnableDatabasePersistence = false, + AttendanceTextFilePath = "", + AttendanceTableName = "dbo.HikvisionAttendanceEvents", + HrExportPath = "", + AutoDoorControlOnSuccess = true, + AutoDoorCloseDelaySeconds = 3, + AcsHistoryMajor = 0, + AcsHistoryMinor = 0, + HistoricalFetchIntervalMinutes = 0, + HistoricalFetchLookbackMinutes = 1440 + }; + } + + var raw = File.ReadAllText(configPath); + var serializer = new DataContractJsonSerializer(typeof(HikvisionServiceConfig)); + + // First attempt: strict JSON. + try + { + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw))) + { + var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); + NormalizeLoadedDeviceConfig(cfg); + return cfg; + } + } + catch + { + // Second attempt: tolerate // and /* */ comments (your serviceconfig.json includes them). + var sanitized = StripJsonComments(raw); + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sanitized))) + { + var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); + NormalizeLoadedDeviceConfig(cfg); + return cfg; + } + } + } + catch + { + return new HikvisionServiceConfig + { + LogDirectory = @"C:\SdkLog", + SqlConnectionString = "", + EnableDatabasePersistence = false, + AttendanceTextFilePath = "", + AttendanceTableName = "dbo.HikvisionAttendanceEvents", + HrExportPath = "", + AutoDoorControlOnSuccess = true, + AutoDoorCloseDelaySeconds = 3, + AcsHistoryMajor = 0, + AcsHistoryMinor = 0, + HistoricalFetchIntervalMinutes = 0, + HistoricalFetchLookbackMinutes = 1440 + }; + } + } + + private static string StripJsonComments(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + var sb = new StringBuilder(input.Length); + bool inString = false; + char stringQuote = '\0'; + bool escape = false; + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + if (inString) + { + sb.Append(c); + if (escape) + { + escape = false; + continue; + } + + if (c == '\\') + { + escape = true; + continue; + } + + if (c == stringQuote) + { + inString = false; + stringQuote = '\0'; + } + + continue; + } + + // Not in a string + if (c == '"' || c == '\'') + { + // JSON strings are double-quoted; single-quote is not valid JSON, + // but supporting it here doesn't hurt for comment stripping. + inString = true; + stringQuote = c; + sb.Append(c); + continue; + } + + // Line comment // + if (c == '/' && i + 1 < input.Length && input[i + 1] == '/') + { + i += 1; // consume second '/' + // skip until newline or end + while (i + 1 < input.Length) + { + i += 1; + if (input[i] == '\r' || input[i] == '\n') + { + sb.Append(input[i]); + break; + } + } + continue; + } + + // Block comment /* ... */ + if (c == '/' && i + 1 < input.Length && input[i + 1] == '*') + { + i += 1; // consume '*' + while (i + 1 < input.Length) + { + i += 1; + if (input[i] == '*' && i + 1 < input.Length && input[i + 1] == '/') + { + i += 1; // consume '/' + break; + } + } + continue; + } + + sb.Append(c); + } + + return sb.ToString(); + } + + private static void NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg) + { + if (cfg?.Devices == null) + return; + + foreach (var d in cfg.Devices) + { + d.DeviceId = DeviceIdentity.NormalizeConfigured(d.DeviceId); + if (!string.IsNullOrWhiteSpace(d.Ip)) + d.Ip = d.Ip.Trim(); + } + } + } + + [DataContract] + internal sealed class DeviceConfig + { + [DataMember] + public string DeviceId { get; set; } = ""; + + [DataMember] + public string Ip { get; set; } = ""; + + [DataMember] + public int Port { get; set; } = 8000; + + [DataMember] + public string Username { get; set; } = ""; + + [DataMember] + public string Password { get; set; } = ""; + + // Door index used by NET_DVR_ControlGateway for manual open/close. + [DataMember] + public int GatewayDoorIndex { get; set; } = 1; + + // For template enrollment/sync (optional). + [DataMember] + public int FingerPrintReaderNo { get; set; } = 1; + + [DataMember] + public int FaceReaderNo { get; set; } = 1; + + /// Optional: documented in JSON for support logs (not sent to SDK). + [DataMember] + public string Model { get; set; } = ""; + + [DataMember] + public string SerialNumber { get; set; } = ""; + + [DataMember] + public string FirmwareVersion { get; set; } = ""; + + [DataMember] + public string SubnetMask { get; set; } = ""; + + [DataMember] + public string DefaultGateway { get; set; } = ""; + } + + internal sealed class FileLogger : IDisposable + { + private readonly string _directory; + private readonly object _sync = new(); + + public FileLogger(string directory) + { + _directory = directory; + Directory.CreateDirectory(_directory); + } + + public void Info(string message) => Write("INFO", message); + public void Warn(string message) => Write("WARN", message); + public void Error(string message, Exception? ex = null) => Write("ERROR", message + (ex is null ? "" : $" | {ex}")); + + private void Write(string level, string message) + { + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; + var logName = "attendance_logs_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt"; + var path = Path.Combine(_directory, logName); + lock (_sync) + { + File.AppendAllText(path, line); + } + } + + public void Dispose() { } + } + + // Manager implementation is in a separate file below for readability. +} + diff --git a/Interop/AppsDemoStubs.cs b/Interop/AppsDemoStubs.cs new file mode 100644 index 0000000..7ee6c2d --- /dev/null +++ b/Interop/AppsDemoStubs.cs @@ -0,0 +1,27 @@ +using System; + +namespace Common +{ + // The AppsDemo interop file includes optional UI/plugin helpers (e.g., AddLog) + // that reference types from the full demo application. Our Windows Service + // doesn't use those helpers, so we provide minimal stubs to satisfy the compiler. + internal interface IDeviceTree + { + DeviceInfo GetSelectedDeviceInfo(); + + sealed class DeviceInfo + { + public string sDeviceIP; + public string sDeviceName; + } + } + + internal static class PluginsFactory + { + public static IDeviceTree GetDeviceTreeInstance() + { + return null; // not used by the service runtime + } + } +} + diff --git a/Interop/HikvisionTemplateInterop.cs b/Interop/HikvisionTemplateInterop.cs new file mode 100644 index 0000000..d4ca494 --- /dev/null +++ b/Interop/HikvisionTemplateInterop.cs @@ -0,0 +1,274 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace HikvisionAttendanceService.Interop; + +internal static class HikvisionTemplateInterop +{ + // These wrappers allow template query/delete/sync with direct HCNetSDK.dll calls. + [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] + private static extern bool NET_DVR_SetDeviceConfig( + int lUserID, + uint dwCommand, + int lChannel, + IntPtr lpInBuffer, + uint dwInBufferSize, + IntPtr lpStatusList, + IntPtr lpInParamBuffer, + uint dwInParamBufferSize); + + [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] + private static extern bool NET_DVR_GetDeviceConfig( + int lUserID, + uint dwCommand, + int lChannel, + IntPtr lpOutBuffer, + uint dwOutBufferSize, + ref uint lpBytesReturned, + IntPtr lpStatusList, + IntPtr lpInParamBuffer, + uint dwInParamBufferSize); + + // Command IDs are the official ACS face/fingerprint config commands. + private const uint NET_DVR_SET_FACE_PARAM_CFG = 2568; + private const uint NET_DVR_GET_FACE_PARAM_CFG = 2569; + private const uint NET_DVR_DEL_FACE_PARAM_CFG = 2570; + private const uint NET_DVR_SET_FINGERPRINT_PARAM = 2573; + private const uint NET_DVR_GET_FINGERPRINT_PARAM = 2574; + private const uint NET_DVR_DEL_FINGERPRINT_PARAM = 2575; + + [StructLayout(LayoutKind.Sequential)] + internal struct NET_DVR_FACE_PARAM_CFG + { + public uint dwSize; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public byte[] byCardNo; + public uint dwFaceLen; + public IntPtr pFaceBuffer; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 128)] + public byte[] byRes; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NET_DVR_FINGERPRINT_PARAM + { + public uint dwSize; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public byte[] byCardNo; + public byte byFingerPrintID; + public byte byEnableCardReader; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] + public byte[] byRes1; + public uint dwFingerPrintLen; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2048)] + public byte[] byFingerData; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 64)] + public byte[] byRes2; + } + + internal static bool SetFaceParam(int userId, int channel, string cardNo, byte[] faceData, out string error) + { + error = ""; + var cfg = new NET_DVR_FACE_PARAM_CFG + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FACE_PARAM_CFG)), + byCardNo = new byte[32], + byRes = new byte[128], + dwFaceLen = (uint)(faceData?.Length ?? 0), + pFaceBuffer = IntPtr.Zero + }; + CopyAscii(cardNo, cfg.byCardNo); + + IntPtr cfgPtr = IntPtr.Zero; + IntPtr facePtr = IntPtr.Zero; + try + { + if (faceData == null || faceData.Length == 0) + { + error = "faceData is empty"; + return false; + } + + facePtr = Marshal.AllocHGlobal(faceData.Length); + Marshal.Copy(faceData, 0, facePtr, faceData.Length); + cfg.pFaceBuffer = facePtr; + + cfgPtr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, cfgPtr, false); + + return NET_DVR_SetDeviceConfig(userId, NET_DVR_SET_FACE_PARAM_CFG, channel, cfgPtr, cfg.dwSize, IntPtr.Zero, IntPtr.Zero, 0); + } + finally + { + if (cfgPtr != IntPtr.Zero) Marshal.FreeHGlobal(cfgPtr); + if (facePtr != IntPtr.Zero) Marshal.FreeHGlobal(facePtr); + } + } + + internal static bool GetFaceParam(int userId, int channel, string cardNo, out byte[] faceData) + { + faceData = Array.Empty(); + var cfg = new NET_DVR_FACE_PARAM_CFG + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FACE_PARAM_CFG)), + byCardNo = new byte[32], + byRes = new byte[128] + }; + CopyAscii(cardNo, cfg.byCardNo); + + uint returned = 0; + IntPtr cfgPtr = IntPtr.Zero; + try + { + cfgPtr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, cfgPtr, false); + + if (!NET_DVR_GetDeviceConfig(userId, NET_DVR_GET_FACE_PARAM_CFG, channel, cfgPtr, cfg.dwSize, ref returned, IntPtr.Zero, IntPtr.Zero, 0)) + return false; + + var outCfg = Marshal.PtrToStructure(cfgPtr); + if (outCfg.dwFaceLen == 0 || outCfg.pFaceBuffer == IntPtr.Zero) + return true; + + faceData = new byte[outCfg.dwFaceLen]; + Marshal.Copy(outCfg.pFaceBuffer, faceData, 0, (int)outCfg.dwFaceLen); + return true; + } + finally + { + if (cfgPtr != IntPtr.Zero) Marshal.FreeHGlobal(cfgPtr); + } + } + + internal static bool DeleteFaceParam(int userId, int channel, string cardNo) + { + var cfg = new NET_DVR_FACE_PARAM_CFG + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FACE_PARAM_CFG)), + byCardNo = new byte[32], + byRes = new byte[128] + }; + CopyAscii(cardNo, cfg.byCardNo); + + IntPtr cfgPtr = IntPtr.Zero; + try + { + cfgPtr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, cfgPtr, false); + return NET_DVR_SetDeviceConfig(userId, NET_DVR_DEL_FACE_PARAM_CFG, channel, cfgPtr, cfg.dwSize, IntPtr.Zero, IntPtr.Zero, 0); + } + finally + { + if (cfgPtr != IntPtr.Zero) Marshal.FreeHGlobal(cfgPtr); + } + } + + internal static bool SetFingerprintParam(int userId, int channel, string cardNo, byte fingerId, byte[] fpData) + { + var cfg = new NET_DVR_FINGERPRINT_PARAM + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FINGERPRINT_PARAM)), + byCardNo = new byte[32], + byRes1 = new byte[2], + byFingerData = new byte[2048], + byRes2 = new byte[64], + byFingerPrintID = fingerId, + byEnableCardReader = 1, + dwFingerPrintLen = (uint)Math.Min(fpData?.Length ?? 0, 2048) + }; + CopyAscii(cardNo, cfg.byCardNo); + if (fpData != null && fpData.Length > 0) + Buffer.BlockCopy(fpData, 0, cfg.byFingerData, 0, (int)cfg.dwFingerPrintLen); + + IntPtr ptr = IntPtr.Zero; + try + { + ptr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, ptr, false); + return NET_DVR_SetDeviceConfig(userId, NET_DVR_SET_FINGERPRINT_PARAM, channel, ptr, cfg.dwSize, IntPtr.Zero, IntPtr.Zero, 0); + } + finally + { + if (ptr != IntPtr.Zero) Marshal.FreeHGlobal(ptr); + } + } + + internal static bool GetFingerprintParam(int userId, int channel, string cardNo, byte fingerId, out byte[] fpData) + { + fpData = Array.Empty(); + var cfg = new NET_DVR_FINGERPRINT_PARAM + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FINGERPRINT_PARAM)), + byCardNo = new byte[32], + byRes1 = new byte[2], + byFingerData = new byte[2048], + byRes2 = new byte[64], + byFingerPrintID = fingerId, + byEnableCardReader = 1 + }; + CopyAscii(cardNo, cfg.byCardNo); + + uint returned = 0; + IntPtr ptr = IntPtr.Zero; + try + { + ptr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, ptr, false); + if (!NET_DVR_GetDeviceConfig(userId, NET_DVR_GET_FINGERPRINT_PARAM, channel, ptr, cfg.dwSize, ref returned, IntPtr.Zero, IntPtr.Zero, 0)) + return false; + + var outCfg = Marshal.PtrToStructure(ptr); + if (outCfg.dwFingerPrintLen == 0) + return true; + + fpData = new byte[outCfg.dwFingerPrintLen]; + Buffer.BlockCopy(outCfg.byFingerData, 0, fpData, 0, (int)outCfg.dwFingerPrintLen); + return true; + } + finally + { + if (ptr != IntPtr.Zero) Marshal.FreeHGlobal(ptr); + } + } + + internal static bool DeleteFingerprintParam(int userId, int channel, string cardNo, byte fingerId) + { + var cfg = new NET_DVR_FINGERPRINT_PARAM + { + dwSize = (uint)Marshal.SizeOf(typeof(NET_DVR_FINGERPRINT_PARAM)), + byCardNo = new byte[32], + byRes1 = new byte[2], + byFingerData = new byte[2048], + byRes2 = new byte[64], + byFingerPrintID = fingerId, + byEnableCardReader = 1 + }; + CopyAscii(cardNo, cfg.byCardNo); + + IntPtr ptr = IntPtr.Zero; + try + { + ptr = Marshal.AllocHGlobal((int)cfg.dwSize); + Marshal.StructureToPtr(cfg, ptr, false); + return NET_DVR_SetDeviceConfig(userId, NET_DVR_DEL_FINGERPRINT_PARAM, channel, ptr, cfg.dwSize, IntPtr.Zero, IntPtr.Zero, 0); + } + finally + { + if (ptr != IntPtr.Zero) Marshal.FreeHGlobal(ptr); + } + } + + private static void CopyAscii(string text, byte[] target) + { + if (target == null || target.Length == 0) + return; + + if (string.IsNullOrWhiteSpace(text)) + return; + + var bytes = Encoding.ASCII.GetBytes(text.Trim()); + var len = Math.Min(bytes.Length, target.Length); + Buffer.BlockCopy(bytes, 0, target, 0, len); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..6069bf1 --- /dev/null +++ b/Program.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; +using System.ServiceProcess; +using System.Threading.Tasks; + +namespace HikvisionAttendanceService; + +internal static class Program +{ + private static bool ArgsContainTestFlag(string[] args) => + args.Any(a => string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase)); + + private static async Task Main(string[] args) + { + // Console so mis-invocation is visible even before the daily log file is opened. + if (ArgsContainTestFlag(args)) + Console.WriteLine("Program entry (test): rawArgCount=" + args.Length + " rawArgs=" + string.Join(" ", args)); + + if (ArgsContainTestFlag(args)) + { + await AttendanceTestMode.RunAsync(args).ConfigureAwait(false); + return; + } + + ServiceBase.Run(new ServiceBase[] + { + new HikvisionAttendanceWindowsService() + }); + } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..452d107 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "HikvisionAttendanceService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/UserTemplateExportPayload.cs b/UserTemplateExportPayload.cs new file mode 100644 index 0000000..4767511 --- /dev/null +++ b/UserTemplateExportPayload.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace HikvisionAttendanceService; + +/// JSON-serializable result of exporting face + fingerprint templates for one user (SDK key = card number / employee string). +internal sealed class UserTemplateExportPayload +{ + public string exportedAtUtc { get; set; } = ""; + public string deviceId { get; set; } = ""; + public string cardNo { get; set; } = ""; + /// Documents the actual SDK calls used in this build. + public string sdkImplementationNote { get; set; } = + "Face/Fingerprints exported via NET_DVR_StartRemoteConfig (preferred), with ISAPI fallback via NET_DVR_STDXMLConfig."; + + public FaceTemplateExportItem face { get; set; } = new FaceTemplateExportItem(); + public List fingerprints { get; set; } = new List(); +} + +internal sealed class FaceTemplateExportItem +{ + public bool attempted { get; set; } + public bool present { get; set; } + public int byteLength { get; set; } + public string dataBase64 { get; set; } = ""; + public string error { get; set; } = ""; +} + +internal sealed class FingerprintTemplateExportItem +{ + public int fingerId { get; set; } + public bool attempted { get; set; } + public bool present { get; set; } + public int byteLength { get; set; } + public string dataBase64 { get; set; } = ""; + public string error { get; set; } = ""; +}