Initial Commit
commit
2455f71296
|
|
@ -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
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
using System;
|
||||
using CHCNetSDK = EventByDeploy.CHCNetSDK;
|
||||
|
||||
namespace HikvisionAttendanceService;
|
||||
|
||||
/// <summary>
|
||||
/// ACS-specific parsing helpers (major/minor codes from HCNetSDK).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps SDK verify mode / card reader kind to a coarse attendance method.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UserTemplateExportPayload> users { get; set; } = new List<UserTemplateExportPayload>();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
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]
|
||||
/// </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:\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 <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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace HikvisionAttendanceService;
|
||||
|
||||
/// <summary>
|
||||
/// Single place for DeviceId trimming and session/fetch lookup keys so config, CLI, and logs stay aligned.
|
||||
/// </summary>
|
||||
internal static class DeviceIdentity
|
||||
{
|
||||
private static readonly Regex FMNumericSuffix = new Regex(@"-FM(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Value stored from JSON after load — trim only; never replace user’s chosen id.</summary>
|
||||
public static string NormalizeConfigured(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return "";
|
||||
return raw.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable key for FindSession / fetch: same physical device if only the "-FM" serial prefix differs
|
||||
/// (e.g. DS-K1T642MFW-FM7378360 vs DS-K1T642MFW-7378360).
|
||||
/// </summary>
|
||||
public static string CanonicalLookupKey(string? raw)
|
||||
{
|
||||
var s = NormalizeConfigured(raw);
|
||||
if (s.Length == 0)
|
||||
return "";
|
||||
|
||||
s = FMNumericSuffix.Replace(s, "-$1");
|
||||
return s.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,43 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-HikvisionAttendanceService-be7e0e28-c864-4273-ade4-819e35c57527</UserSecretsId>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Hikvision interop (imported directly from the SDK demo source files) -->
|
||||
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\HCNetSDK.cs"
|
||||
Link="Interop\EventByDeploy\HCNetSDK.cs" />
|
||||
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\TypeMap.cs"
|
||||
Link="Interop\EventByDeploy\TypeMap.cs" />
|
||||
<!-- Login_V30 + core callback helpers -->
|
||||
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\9-AppsDemo_build20201230191326\CommonBase\Head\HCNetSDK.cs"
|
||||
Link="Interop\AppsDemo\CommonBase\HCNetSDK_LoginV30.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ensure serviceconfig is copied next to the exe -->
|
||||
<None Include="serviceconfig.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Copy native SDK DLLs into output/HCNetSDK so AppsDemo interop can resolve them -->
|
||||
<Content Include="HCNetSDK\**\*.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- ServiceBase -->
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<!-- Needed for JavaScriptSerializer JSON parsing in STDXMLConfig diagnostics -->
|
||||
<Reference Include="System.Web.Extensions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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
|
||||
|
|
@ -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; } = "";
|
||||
|
||||
/// <summary>When true and SqlConnectionString is set, attendance rows are INSERTed. Default false until schema is finalized.</summary>
|
||||
[DataMember]
|
||||
public bool EnableDatabasePersistence { get; set; }
|
||||
|
||||
/// <summary>Plain-text attendance log. Empty = LogDirectory\attendance_records.txt</summary>
|
||||
[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<DeviceConfig> Devices { get; set; } = new List<DeviceConfig>();
|
||||
|
||||
/// <summary>0 = all majors (per GetACSEvent demo).</summary>
|
||||
[DataMember]
|
||||
public uint AcsHistoryMajor { get; set; }
|
||||
|
||||
/// <summary>0 = all minors.</summary>
|
||||
[DataMember]
|
||||
public uint AcsHistoryMinor { get; set; }
|
||||
|
||||
/// <summary>0 = disabled. Otherwise interval in minutes for NET_DVR_GET_ACS_EVENT sync.</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Optional: documented in JSON for support logs (not sent to SDK).</summary>
|
||||
[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.
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<byte>();
|
||||
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<NET_DVR_FACE_PARAM_CFG>(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<byte>();
|
||||
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<NET_DVR_FINGERPRINT_PARAM>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"HikvisionAttendanceService": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace HikvisionAttendanceService;
|
||||
|
||||
/// <summary>JSON-serializable result of exporting face + fingerprint templates for one user (SDK key = card number / employee string).</summary>
|
||||
internal sealed class UserTemplateExportPayload
|
||||
{
|
||||
public string exportedAtUtc { get; set; } = "";
|
||||
public string deviceId { get; set; } = "";
|
||||
public string cardNo { get; set; } = "";
|
||||
/// <summary>Documents the actual SDK calls used in this build.</summary>
|
||||
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<FingerprintTemplateExportItem> fingerprints { get; set; } = new List<FingerprintTemplateExportItem>();
|
||||
}
|
||||
|
||||
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; } = "";
|
||||
}
|
||||
Loading…
Reference in New Issue