hikvision-sync-service/AttendanceTestMode.cs

469 lines
22 KiB
C#

using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace HikvisionAttendanceService;
/// <summary>
/// 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)
/// </summary>
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 = "";
int discoveredUsers = 0;
string exportErr = "";
bool exportOk = await Task.Run(() =>
manager.TryExportAllUsersTemplatesToIsapiFile(
exportAllIsapiDeviceId,
string.IsNullOrEmpty(exportAllIsapiOutDir) ? null : exportAllIsapiOutDir,
exportAllIsapiPageSize,
exportAllIsapiMaxUsers,
cts.Token,
out jsonPath,
out discoveredUsers,
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 <CardNoOrEmployeeId> (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 <DeviceId>)";
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);
}
}