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] /// HikvisionAttendanceService.exe --test --user-sync-once (runs one RunUserFaceSyncCycle from serviceconfig.json; requires EnableUserSync + source/targets) /// 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:\Users\Public\HikvisionAttendanceService"); 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 userSyncOnceWanted = args.Any(a => string.Equals(a, "--user-sync-once", StringComparison.OrdinalIgnoreCase)); 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: userSyncOnceFlagPresent=" + userSyncOnceWanted); 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 (userSyncOnceWanted) { logger.Info("TEST mode: --user-sync-once running RunUserFaceSyncCycle (uses SourceDeviceId, TargetDeviceIds, SyncPolicies from config)."); Console.WriteLine("Running one user/face sync cycle..."); try { await Task.Run(() => manager.RunUserFaceSyncCycle(cts.Token), cts.Token).ConfigureAwait(false); Console.WriteLine("User sync cycle finished. See logs under: " + config.LogDirectory); } catch (Exception ex) { logger.Error("TEST mode: user-sync-once failed", ex); Console.WriteLine("User sync failed: " + ex.Message); } Console.WriteLine("Stopping test host..."); cts.Cancel(); } else 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); } }