hikvision-sync-service/HikvisionAttendanceWindowsS...

639 lines
24 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; } = "";
[DataMember]
public bool EnableDbIntegration { get; set; }
[DataMember]
public bool EnableDbMachineLoading { get; set; }
[DataMember]
public bool PreferDbMachinesOverConfig { get; set; }
[DataMember]
public bool EnableAttendanceDbPersistence { get; set; }
[DataMember]
public bool EnableTemplateDbPersistence { get; set; }
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
[DataMember]
public bool KeepAttendanceFileExport { get; set; } = true;
[DataMember]
public bool KeepTemplateFiles { get; set; } = true;
[DataMember]
public string DbHost { get; set; } = "";
[DataMember]
public int DbPort { get; set; } = 3306;
[DataMember]
public string DbName { get; set; } = "hrms";
[DataMember]
public string DbUsername { get; set; } = "";
[DataMember]
public string DbPassword { get; set; } = "";
[DataMember]
public string DbConnectionString { 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 &lt;= 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 = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
DbPort = 3306,
DbName = "hrms",
DbUsername = "",
DbPassword = "",
DbConnectionString = "",
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);
ApplyEnvironmentOverrides(cfg);
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);
ApplyEnvironmentOverrides(cfg);
NormalizeLoadedDeviceConfig(cfg);
return cfg;
}
}
}
catch
{
return new HikvisionServiceConfig
{
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
SqlConnectionString = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
DbPort = 3306,
DbName = "hrms",
DbUsername = "",
DbPassword = "",
DbConnectionString = "",
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 ApplyEnvironmentOverrides(HikvisionServiceConfig cfg)
{
if (cfg == null)
return;
string ReadEnv(string key) => Environment.GetEnvironmentVariable(key) ?? "";
var cs = ReadEnv("HIKVISION_DB_CONNECTION_STRING");
var host = ReadEnv("HIKVISION_DB_HOST");
var user = ReadEnv("HIKVISION_DB_USERNAME");
var pass = ReadEnv("HIKVISION_DB_PASSWORD");
var dbName = ReadEnv("HIKVISION_DB_NAME");
var dbPort = ReadEnv("HIKVISION_DB_PORT");
if (!string.IsNullOrWhiteSpace(cs))
cfg.DbConnectionString = cs.Trim();
if (!string.IsNullOrWhiteSpace(host))
cfg.DbHost = host.Trim();
if (!string.IsNullOrWhiteSpace(user))
cfg.DbUsername = user.Trim();
if (!string.IsNullOrWhiteSpace(pass))
cfg.DbPassword = pass;
if (!string.IsNullOrWhiteSpace(dbName))
cfg.DbName = dbName.Trim();
if (int.TryParse(dbPort, out var parsedPort) && parsedPort > 0)
cfg.DbPort = parsedPort;
}
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.
}