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; } = ""; /// When true and SqlConnectionString is set, attendance rows are INSERTed. Default false until schema is finalized. [DataMember] public bool EnableDatabasePersistence { get; set; } /// Plain-text attendance log. Empty = LogDirectory\attendance_records.txt [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 Devices { get; set; } = new List(); /// 0 = all majors (per GetACSEvent demo). [DataMember] public uint AcsHistoryMajor { get; set; } /// 0 = all minors. [DataMember] public uint AcsHistoryMinor { get; set; } /// 0 = disabled. Otherwise interval in minutes for NET_DVR_GET_ACS_EVENT sync. [DataMember] public int HistoricalFetchIntervalMinutes { get; set; } [DataMember] public int HistoricalFetchLookbackMinutes { get; set; } = 1440; /// Enable/disable scheduled attendance historical sync job. [DataMember] public bool EnableAttendanceSync { get; set; } = true; /// Scheduled attendance sync interval in minutes (default 5). [DataMember] public int AttendanceSyncIntervalMinutes { get; set; } = 5; /// /// 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. /// [DataMember] public bool UseIsapiHttpForUserInfo { get; set; } = true; /// HTTP port for direct ISAPI calls. 80 by default. [DataMember] public int IsapiHttpPort { get; set; } = 80; /// When true, periodically syncs users and face images from to via ISAPI HTTP (Digest). [DataMember] public bool EnableUserSync { get; set; } /// Interval between user/face sync cycles. Ignored if <= 0 or sync is disabled. [DataMember] public int SyncIntervalMinutes { get; set; } = 60; /// Enable/disable scheduled face/fingerprint template export/fetch job. [DataMember] public bool EnableTemplateFetch { get; set; } /// Template fetch interval in hours (default 4). [DataMember] public int TemplateFetchIntervalHours { get; set; } = 4; /// DeviceId (from ) to treat as the authoritative user source. [DataMember] public string SourceDeviceId { get; set; } = ""; /// DeviceIds to push missing users/faces onto. [DataMember] public List TargetDeviceIds { get; set; } = new List(); /// Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save. [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(), 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(), 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(); 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(); } } } /// Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only. [DataContract] internal sealed class UserSyncPoliciesConfig { /// When true, attempts UserInfo/Modify on targets when name or validity differs from source. [DataMember] public bool UpdateExistingUserFields { get; set; } /// When true (default), face upload runs only if target has no enrolled face (numOfFace == 0). [DataMember] public bool UploadFaceIfMissingOnly { get; set; } = true; /// When true, deletes users on targets that are not present on the source (destructive). [DataMember] public bool DeleteOnTargetIfMissingInSource { get; set; } /// Face library type for FaceDataRecord upload (device-specific; Pro Series often uses blackFD). [DataMember] public string FaceLibType { get; set; } = "blackFD"; /// Face library ID for FaceDataRecord upload. [DataMember] public string FaceLibraryFdId { get; set; } = "1"; /// Max retries per HTTP call (exponential backoff between attempts). [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; /// Optional: documented in JSON for support logs (not sent to SDK). [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. }