539 lines
20 KiB
C#
539 lines
20 KiB
C#
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:\Users\Public\HikvisionAttendanceService";
|
|
|
|
[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;
|
|
|
|
/// <summary>Enable/disable scheduled attendance historical sync job.</summary>
|
|
[DataMember]
|
|
public bool EnableAttendanceSync { get; set; } = true;
|
|
|
|
/// <summary>Scheduled attendance sync interval in minutes (default 5).</summary>
|
|
[DataMember]
|
|
public int AttendanceSyncIntervalMinutes { get; set; } = 5;
|
|
|
|
/// <summary>
|
|
/// When true, user-info/faceURL lookups use direct ISAPI HTTP (Digest auth) instead of SDK STDXML wrappers.
|
|
/// Default true because some terminals return richer UserInfo fields (e.g. faceURL) over HTTP.
|
|
/// </summary>
|
|
[DataMember]
|
|
public bool UseIsapiHttpForUserInfo { get; set; } = true;
|
|
|
|
/// <summary>HTTP port for direct ISAPI calls. 80 by default.</summary>
|
|
[DataMember]
|
|
public int IsapiHttpPort { get; set; } = 80;
|
|
|
|
/// <summary>When true, periodically syncs users and face images from <see cref="SourceDeviceId"/> to <see cref="TargetDeviceIds"/> via ISAPI HTTP (Digest).</summary>
|
|
[DataMember]
|
|
public bool EnableUserSync { get; set; }
|
|
|
|
/// <summary>Interval between user/face sync cycles. Ignored if <= 0 or sync is disabled.</summary>
|
|
[DataMember]
|
|
public int SyncIntervalMinutes { get; set; } = 60;
|
|
|
|
/// <summary>Enable/disable scheduled face/fingerprint template export/fetch job.</summary>
|
|
[DataMember]
|
|
public bool EnableTemplateFetch { get; set; }
|
|
|
|
/// <summary>Template fetch interval in hours (default 4).</summary>
|
|
[DataMember]
|
|
public int TemplateFetchIntervalHours { get; set; } = 4;
|
|
|
|
/// <summary>DeviceId (from <see cref="DeviceConfig.DeviceId"/>) to treat as the authoritative user source.</summary>
|
|
[DataMember]
|
|
public string SourceDeviceId { get; set; } = "";
|
|
|
|
/// <summary>DeviceIds to push missing users/faces onto.</summary>
|
|
[DataMember]
|
|
public List<string> TargetDeviceIds { get; set; } = new List<string>();
|
|
|
|
/// <summary>Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save.</summary>
|
|
[DataMember]
|
|
public string UserSyncFaceCacheDirectory { get; set; } = "";
|
|
|
|
[DataMember]
|
|
public UserSyncPoliciesConfig SyncPolicies { get; set; } = new UserSyncPoliciesConfig();
|
|
|
|
public static HikvisionServiceConfig Load(string configPath)
|
|
{
|
|
// Minimal JSON config loader (no external packages).
|
|
try
|
|
{
|
|
if (!File.Exists(configPath))
|
|
{
|
|
return new HikvisionServiceConfig
|
|
{
|
|
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
|
|
SqlConnectionString = "",
|
|
EnableDatabasePersistence = false,
|
|
AttendanceTextFilePath = "",
|
|
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
|
|
HrExportPath = "",
|
|
AutoDoorControlOnSuccess = true,
|
|
AutoDoorCloseDelaySeconds = 3,
|
|
AcsHistoryMajor = 0,
|
|
AcsHistoryMinor = 0,
|
|
HistoricalFetchIntervalMinutes = 0,
|
|
HistoricalFetchLookbackMinutes = 1440,
|
|
EnableAttendanceSync = true,
|
|
AttendanceSyncIntervalMinutes = 5,
|
|
UseIsapiHttpForUserInfo = true,
|
|
IsapiHttpPort = 8000,
|
|
EnableTemplateFetch = false,
|
|
TemplateFetchIntervalHours = 4,
|
|
EnableUserSync = false,
|
|
SyncIntervalMinutes = 60,
|
|
SourceDeviceId = "",
|
|
TargetDeviceIds = new List<string>(),
|
|
UserSyncFaceCacheDirectory = "",
|
|
SyncPolicies = new UserSyncPoliciesConfig()
|
|
};
|
|
}
|
|
|
|
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:\Users\Public\HikvisionAttendanceService",
|
|
SqlConnectionString = "",
|
|
EnableDatabasePersistence = false,
|
|
AttendanceTextFilePath = "",
|
|
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
|
|
HrExportPath = "",
|
|
AutoDoorControlOnSuccess = true,
|
|
AutoDoorCloseDelaySeconds = 3,
|
|
AcsHistoryMajor = 0,
|
|
AcsHistoryMinor = 0,
|
|
HistoricalFetchIntervalMinutes = 0,
|
|
HistoricalFetchLookbackMinutes = 1440,
|
|
EnableAttendanceSync = true,
|
|
AttendanceSyncIntervalMinutes = 5,
|
|
UseIsapiHttpForUserInfo = true,
|
|
IsapiHttpPort = 80,
|
|
EnableTemplateFetch = false,
|
|
TemplateFetchIntervalHours = 4,
|
|
EnableUserSync = false,
|
|
SyncIntervalMinutes = 60,
|
|
SourceDeviceId = "",
|
|
TargetDeviceIds = new List<string>(),
|
|
UserSyncFaceCacheDirectory = "",
|
|
SyncPolicies = new UserSyncPoliciesConfig()
|
|
};
|
|
}
|
|
}
|
|
|
|
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 == null)
|
|
return;
|
|
|
|
cfg.SourceDeviceId = DeviceIdentity.NormalizeConfigured(cfg.SourceDeviceId);
|
|
cfg.UserSyncFaceCacheDirectory = string.IsNullOrWhiteSpace(cfg.UserSyncFaceCacheDirectory)
|
|
? ""
|
|
: cfg.UserSyncFaceCacheDirectory.Trim();
|
|
cfg.SyncPolicies ??= new UserSyncPoliciesConfig();
|
|
if (cfg.TargetDeviceIds == null)
|
|
cfg.TargetDeviceIds = new List<string>();
|
|
else
|
|
{
|
|
for (int i = 0; i < cfg.TargetDeviceIds.Count; i++)
|
|
cfg.TargetDeviceIds[i] = DeviceIdentity.NormalizeConfigured(cfg.TargetDeviceIds[i]);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only.</summary>
|
|
[DataContract]
|
|
internal sealed class UserSyncPoliciesConfig
|
|
{
|
|
/// <summary>When true, attempts UserInfo/Modify on targets when name or validity differs from source.</summary>
|
|
[DataMember]
|
|
public bool UpdateExistingUserFields { get; set; }
|
|
|
|
/// <summary>When true (default), face upload runs only if target has no enrolled face (numOfFace == 0).</summary>
|
|
[DataMember]
|
|
public bool UploadFaceIfMissingOnly { get; set; } = true;
|
|
|
|
/// <summary>When true, deletes users on targets that are not present on the source (destructive).</summary>
|
|
[DataMember]
|
|
public bool DeleteOnTargetIfMissingInSource { get; set; }
|
|
|
|
/// <summary>Face library type for FaceDataRecord upload (device-specific; Pro Series often uses blackFD).</summary>
|
|
[DataMember]
|
|
public string FaceLibType { get; set; } = "blackFD";
|
|
|
|
/// <summary>Face library ID for FaceDataRecord upload.</summary>
|
|
[DataMember]
|
|
public string FaceLibraryFdId { get; set; } = "1";
|
|
|
|
/// <summary>Max retries per HTTP call (exponential backoff between attempts).</summary>
|
|
[DataMember]
|
|
public int HttpMaxRetries { get; set; } = 3;
|
|
}
|
|
|
|
[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 string _internalLogsDirectory;
|
|
private readonly string _attendanceJobLogsDirectory;
|
|
private readonly string _templateJobLogsDirectory;
|
|
private readonly string _userSyncJobLogsDirectory;
|
|
private readonly object _sync = new();
|
|
|
|
public FileLogger(string directory)
|
|
{
|
|
_directory = directory;
|
|
Directory.CreateDirectory(_directory);
|
|
var logsRoot = Path.Combine(_directory, "logs");
|
|
_internalLogsDirectory = Path.Combine(_directory, "internal_logs");
|
|
_attendanceJobLogsDirectory = Path.Combine(logsRoot, "attendance_logs");
|
|
_templateJobLogsDirectory = Path.Combine(logsRoot, "template_fetching_logs");
|
|
_userSyncJobLogsDirectory = Path.Combine(logsRoot, "user_sync_logs");
|
|
Directory.CreateDirectory(_internalLogsDirectory);
|
|
Directory.CreateDirectory(_attendanceJobLogsDirectory);
|
|
Directory.CreateDirectory(_templateJobLogsDirectory);
|
|
Directory.CreateDirectory(_userSyncJobLogsDirectory);
|
|
}
|
|
|
|
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}"));
|
|
public void JobInfo(string jobName, string message) => WriteJob(jobName, "INFO", message);
|
|
public void JobWarn(string jobName, string message) => WriteJob(jobName, "WARN", message);
|
|
public void JobError(string jobName, string message) => WriteJob(jobName, "ERROR", message);
|
|
|
|
private void Write(string level, string message)
|
|
{
|
|
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
|
|
var logName = "internal_logs_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt";
|
|
var path = Path.Combine(_internalLogsDirectory, logName);
|
|
lock (_sync)
|
|
{
|
|
File.AppendAllText(path, line);
|
|
}
|
|
}
|
|
|
|
private void WriteJob(string jobName, string level, string message)
|
|
{
|
|
string dir;
|
|
string prefix;
|
|
switch ((jobName ?? "").Trim().ToLowerInvariant())
|
|
{
|
|
case "attendance":
|
|
case "attendance_logs":
|
|
dir = _attendanceJobLogsDirectory;
|
|
prefix = "attendance_logs_";
|
|
break;
|
|
case "template":
|
|
case "template_fetch":
|
|
case "template_fetching_logs":
|
|
dir = _templateJobLogsDirectory;
|
|
prefix = "template_fetching_logs_";
|
|
break;
|
|
case "user_sync":
|
|
case "user_sync_logs":
|
|
dir = _userSyncJobLogsDirectory;
|
|
prefix = "user_sync_logs_";
|
|
break;
|
|
default:
|
|
dir = _internalLogsDirectory;
|
|
prefix = "internal_logs_";
|
|
break;
|
|
}
|
|
|
|
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
|
|
var path = Path.Combine(dir, prefix + DateTime.Now.ToString("yyyy-MM-dd") + ".txt");
|
|
lock (_sync)
|
|
{
|
|
File.AppendAllText(path, line);
|
|
}
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
|
|
// Manager implementation is in a separate file below for readability.
|
|
}
|
|
|