hikvision-sync-service/HikvisionAttendanceWindowsS...

969 lines
39 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);
logger.Ops(OpsMarkers.Service, "WINDOWS SERVICE OnStart time=" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
" config=\"" + configPath + "\"");
_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; }
/// <summary>When true, pull face templates from device and save to DB (DEVICE -&gt; DATABASE). Prefer only one template direction enabled at a time.</summary>
[DataMember]
public bool EnableTemplateDeviceToDbSync { get; set; }
/// <summary>When true, push face templates from DB to device (DATABASE -&gt; DEVICE). Prefer only one template direction enabled at a time.</summary>
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
/// <summary>
/// Separate onboarding flow: provision users+faces to a new device from HRMS department
/// + employee portal photos. Does not use attendance_machine_face_templates.
/// </summary>
[DataMember]
public bool EnableInitialDepartmentSync { get; set; }
[DataMember]
public List<string> InitialSyncDepartmentIds { get; set; } = new List<string>();
/// <summary>
/// Optional allow-list of employee serial_numbers. When non-empty, initial sync enrolls only
/// these employees AND only if they belong to <see cref="InitialSyncDepartmentIds"/>. Empty = whole department.
/// </summary>
[DataMember]
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
/// <summary>
/// Required HRMS employee.location_site_id filter for Initial Department Sync.
/// Null or a non-positive value prevents the initial sync from running.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public int? InitialSyncLocationSiteId { get; set; }
/// <summary>When true, initial sync downloads JPEG photos from <see cref="EmployeePhotoBaseUrl"/>.</summary>
[DataMember]
public bool EnableEmployeePhotoSource { get; set; }
/// <summary>Base URL ending with /; photo path is {base}{employee.id}.jpeg</summary>
[DataMember]
public string EmployeePhotoBaseUrl { 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;
[DataMember]
public string MachineScopeMode { get; set; } = "CENTRAL";
[DataMember]
public List<string> ScopedMachineIps { get; set; } = new List<string>();
/// <summary>DeviceId (from <see cref="DeviceConfig.DeviceId"/>) to treat as the authoritative user source.</summary>
[DataMember]
public string SourceDeviceId { get; set; } = "";
[DataMember]
public string SourceMachineIp { get; set; } = "";
/// <summary>DeviceIds to push missing users/faces onto.</summary>
[DataMember]
public List<string> TargetDeviceIds { get; set; } = new List<string>();
[DataMember]
public List<string> TargetMachineIps { get; set; } = new List<string>();
[DataMember]
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
/// <summary>Optional HRMS department_id values; serial_numbers from hrms.employee are merged with SyncEmployeeIds.</summary>
[DataMember]
public List<string> SyncDepartmentIds { 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))
{
var missingCfg = new HikvisionServiceConfig
{
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
SqlConnectionString = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
EnableInitialDepartmentSync = false,
InitialSyncDepartmentIds = new List<string>(),
EnableEmployeePhotoSource = false,
EmployeePhotoBaseUrl = "",
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,
MachineScopeMode = "CENTRAL",
ScopedMachineIps = new List<string>(),
EnableUserSync = false,
SyncIntervalMinutes = 60,
SourceDeviceId = "",
SourceMachineIp = "",
TargetDeviceIds = new List<string>(),
TargetMachineIps = new List<string>(),
SyncEmployeeIds = new List<string>(),
SyncDepartmentIds = new List<string>(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
ApplyDeviceSettingsOverlay(missingCfg, configPath);
NormalizeLoadedDeviceConfig(missingCfg);
return missingCfg;
}
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);
ApplyDeviceSettingsOverlay(cfg, configPath);
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);
ApplyDeviceSettingsOverlay(cfg, configPath);
NormalizeLoadedDeviceConfig(cfg);
return cfg;
}
}
}
catch
{
var fallbackCfg = new HikvisionServiceConfig
{
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
SqlConnectionString = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
EnableInitialDepartmentSync = false,
InitialSyncDepartmentIds = new List<string>(),
EnableEmployeePhotoSource = false,
EmployeePhotoBaseUrl = "",
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,
MachineScopeMode = "CENTRAL",
ScopedMachineIps = new List<string>(),
EnableUserSync = false,
SyncIntervalMinutes = 60,
SourceDeviceId = "",
SourceMachineIp = "",
TargetDeviceIds = new List<string>(),
TargetMachineIps = new List<string>(),
SyncEmployeeIds = new List<string>(),
SyncDepartmentIds = new List<string>(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
ApplyDeviceSettingsOverlay(fallbackCfg, configPath);
NormalizeLoadedDeviceConfig(fallbackCfg);
return fallbackCfg;
}
}
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;
}
/// <summary>
/// Overlays device-scope / template-direction settings from DeviceSettings.config
/// (same folder as serviceconfig.json). Property names and behavior are unchanged.
/// </summary>
private static void ApplyDeviceSettingsOverlay(HikvisionServiceConfig cfg, string serviceConfigPath)
{
if (cfg == null)
return;
string dir;
try
{
dir = Path.GetDirectoryName(Path.GetFullPath(serviceConfigPath ?? "")) ?? "";
}
catch
{
dir = "";
}
if (string.IsNullOrWhiteSpace(dir))
dir = AppContext.BaseDirectory;
var path = Path.Combine(dir, "DeviceSettings.config");
if (!File.Exists(path))
return;
try
{
var raw = File.ReadAllText(path);
var serializer = new DataContractJsonSerializer(typeof(DeviceSettingsFile));
DeviceSettingsFile? overlay;
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw)))
overlay = serializer.ReadObject(ms) as DeviceSettingsFile;
if (overlay == null)
return;
if (!string.IsNullOrWhiteSpace(overlay.MachineScopeMode))
cfg.MachineScopeMode = overlay.MachineScopeMode;
if (overlay.ScopedMachineIps != null)
cfg.ScopedMachineIps = new List<string>(overlay.ScopedMachineIps);
cfg.EnableTemplateDeviceToDbSync = overlay.EnableTemplateDeviceToDbSync;
cfg.EnableTemplateDbToDeviceSync = overlay.EnableTemplateDbToDeviceSync;
if (overlay.SourceMachineIp != null)
cfg.SourceMachineIp = overlay.SourceMachineIp;
if (overlay.TargetMachineIps != null)
cfg.TargetMachineIps = new List<string>(overlay.TargetMachineIps);
if (overlay.SyncEmployeeIds != null)
cfg.SyncEmployeeIds = new List<string>(overlay.SyncEmployeeIds);
if (overlay.SyncDepartmentIds != null)
cfg.SyncDepartmentIds = new List<string>(overlay.SyncDepartmentIds);
cfg.EnableInitialDepartmentSync = overlay.EnableInitialDepartmentSync;
if (overlay.InitialSyncDepartmentIds != null)
cfg.InitialSyncDepartmentIds = new List<string>(overlay.InitialSyncDepartmentIds);
if (overlay.InitialSyncEmployeeIds != null)
cfg.InitialSyncEmployeeIds = new List<string>(overlay.InitialSyncEmployeeIds);
if (overlay.InitialSyncLocationSiteId.HasValue)
cfg.InitialSyncLocationSiteId = overlay.InitialSyncLocationSiteId;
cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource;
if (overlay.EmployeePhotoBaseUrl != null)
cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl;
}
catch
{
// Keep serviceconfig / defaults if DeviceSettings.config cannot be parsed.
}
}
private static void NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg)
{
if (cfg == null)
return;
cfg.MachineScopeMode = string.IsNullOrWhiteSpace(cfg.MachineScopeMode)
? "CENTRAL"
: cfg.MachineScopeMode.Trim().ToUpperInvariant();
cfg.SourceDeviceId = DeviceIdentity.NormalizeConfigured(cfg.SourceDeviceId);
cfg.SourceMachineIp = string.IsNullOrWhiteSpace(cfg.SourceMachineIp) ? "" : cfg.SourceMachineIp.Trim();
cfg.UserSyncFaceCacheDirectory = string.IsNullOrWhiteSpace(cfg.UserSyncFaceCacheDirectory)
? ""
: cfg.UserSyncFaceCacheDirectory.Trim();
cfg.SyncPolicies ??= new UserSyncPoliciesConfig();
cfg.ScopedMachineIps ??= new List<string>();
for (int i = 0; i < cfg.ScopedMachineIps.Count; i++)
cfg.ScopedMachineIps[i] = (cfg.ScopedMachineIps[i] ?? "").Trim();
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]);
}
cfg.TargetMachineIps ??= new List<string>();
for (int i = 0; i < cfg.TargetMachineIps.Count; i++)
cfg.TargetMachineIps[i] = (cfg.TargetMachineIps[i] ?? "").Trim();
cfg.SyncEmployeeIds ??= new List<string>();
for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim();
cfg.SyncDepartmentIds ??= new List<string>();
for (int i = 0; i < cfg.SyncDepartmentIds.Count; i++)
cfg.SyncDepartmentIds[i] = (cfg.SyncDepartmentIds[i] ?? "").Trim();
cfg.InitialSyncDepartmentIds ??= new List<string>();
for (int i = 0; i < cfg.InitialSyncDepartmentIds.Count; i++)
cfg.InitialSyncDepartmentIds[i] = (cfg.InitialSyncDepartmentIds[i] ?? "").Trim();
cfg.InitialSyncEmployeeIds ??= new List<string>();
for (int i = 0; i < cfg.InitialSyncEmployeeIds.Count; i++)
cfg.InitialSyncEmployeeIds[i] = (cfg.InitialSyncEmployeeIds[i] ?? "").Trim();
cfg.EmployeePhotoBaseUrl = string.IsNullOrWhiteSpace(cfg.EmployeePhotoBaseUrl)
? ""
: cfg.EmployeePhotoBaseUrl.Trim();
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>Device-scope and template-direction settings loaded from DeviceSettings.config.</summary>
[DataContract]
internal sealed class DeviceSettingsFile
{
[DataMember]
public string MachineScopeMode { get; set; } = "CENTRAL";
[DataMember]
public List<string> ScopedMachineIps { get; set; } = new List<string>();
[DataMember]
public bool EnableTemplateDeviceToDbSync { get; set; }
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
[DataMember]
public string SourceMachineIp { get; set; } = "";
[DataMember]
public List<string> TargetMachineIps { get; set; } = new List<string>();
[DataMember]
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
[DataMember]
public List<string> SyncDepartmentIds { get; set; } = new List<string>();
[DataMember]
public bool EnableInitialDepartmentSync { get; set; }
[DataMember]
public List<string> InitialSyncDepartmentIds { get; set; } = new List<string>();
[DataMember]
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
[DataMember(EmitDefaultValue = false)]
public int? InitialSyncLocationSiteId { get; set; }
[DataMember]
public bool EnableEmployeePhotoSource { get; set; }
[DataMember]
public string EmployeePhotoBaseUrl { get; set; } = "";
}
/// <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 _attendanceBizDirectory;
private readonly string _templateBizDirectory;
private readonly string _userSyncBizDirectory;
private readonly string _departmentalSyncBizDirectory;
private readonly string _unreachableBizDirectory;
private readonly object _sync = new();
public readonly BizSessionTotals Totals = new BizSessionTotals();
public FileLogger(string directory)
{
_directory = directory;
Directory.CreateDirectory(_directory);
var logsRoot = Path.Combine(_directory, "logs");
_internalLogsDirectory = Path.Combine(_directory, "internal_logs");
_attendanceBizDirectory = Path.Combine(logsRoot, "attendance_logs");
_templateBizDirectory = Path.Combine(logsRoot, "template_fetching_logs");
_userSyncBizDirectory = Path.Combine(logsRoot, "user_sync_logs");
_departmentalSyncBizDirectory = Path.Combine(logsRoot, "departmental_sync_logs");
_unreachableBizDirectory = Path.Combine(_internalLogsDirectory, "unreachable_devices");
Directory.CreateDirectory(_internalLogsDirectory);
Directory.CreateDirectory(_attendanceBizDirectory);
Directory.CreateDirectory(_templateBizDirectory);
Directory.CreateDirectory(_userSyncBizDirectory);
Directory.CreateDirectory(_departmentalSyncBizDirectory);
Directory.CreateDirectory(_unreachableBizDirectory);
}
public string LogDirectory => _directory;
public void Info(string message) => WriteInternal("INFO", message);
public void Warn(string message) => WriteInternal("WARN", message);
public void Error(string message, Exception? ex = null) =>
WriteInternal("ERROR", message + (ex is null ? "" : $" | {ex}"));
/// <summary>Technical diagnostics only — never written to business logs/.</summary>
public void JobInfo(string jobName, string message) => WriteInternalDiag(jobName, "INFO", message);
public void JobWarn(string jobName, string message) => WriteInternalDiag(jobName, "WARN", message);
public void JobError(string jobName, string message) => WriteInternalDiag(jobName, "ERROR", message);
public void Diag(string jobName, string message) => WriteInternalDiag(jobName, "INFO", message);
public void Ops(string marker, string message) =>
WriteInternal("INFO", "[" + (marker ?? "OPS") + "] " + (message ?? ""));
public void OpsWarn(string marker, string message) =>
WriteInternal("WARN", "[" + (marker ?? "OPS") + "] " + (message ?? ""));
public void OpsError(string marker, string message) =>
WriteInternal("ERROR", "[" + (marker ?? "OPS") + "] " + (message ?? ""));
public void Banner(params string[] lines)
{
if (lines == null || lines.Length == 0)
return;
lock (_sync)
{
var sb = new StringBuilder();
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
sb.Append(stamp).Append(" [INFO] ============================================================").Append(Environment.NewLine);
foreach (var line in lines)
sb.Append(stamp).Append(" [INFO] ").Append(line ?? "").Append(Environment.NewLine);
sb.Append(stamp).Append(" [INFO] ============================================================").Append(Environment.NewLine);
File.AppendAllText(InternalLogPath(), sb.ToString());
}
}
// ---- Business / operations logs (human-readable, no SDK) ----
public void Biz(BizChannel channel, params string[] lines)
{
if (lines == null || lines.Length == 0)
return;
var sb = new StringBuilder();
foreach (var line in lines)
sb.AppendLine(line ?? "");
AppendBiz(channel, sb.ToString());
}
public void BizBlank(BizChannel channel) => AppendBiz(channel, Environment.NewLine);
public void BizSeparator(BizChannel channel) =>
AppendBiz(channel, "------------------------------------------------------------" + Environment.NewLine + Environment.NewLine);
public void WriteBizServiceStarted()
{
Totals.StartedAt = DateTime.Now;
var block =
"------------------------------------------------------------" + Environment.NewLine +
"Service started at " + BizFriendlyReasons.FormatTime(Totals.StartedAt) + Environment.NewLine +
"------------------------------------------------------------" + Environment.NewLine +
Environment.NewLine;
AppendBiz(BizChannel.Attendance, block);
AppendBiz(BizChannel.UserSync, block);
AppendBiz(BizChannel.Template, block);
AppendBiz(BizChannel.DepartmentalSync, block);
AppendBiz(BizChannel.Unreachable, block);
}
public void WriteBizServiceStopped()
{
var stopped =
Environment.NewLine +
"------------------------------------------------------------" + Environment.NewLine +
"Service stopped at " + BizFriendlyReasons.FormatTime(DateTime.Now) + Environment.NewLine +
Environment.NewLine +
Totals.FormatSummaryBlock() +
Environment.NewLine;
AppendBiz(BizChannel.Attendance, stopped);
AppendBiz(BizChannel.UserSync, stopped);
AppendBiz(BizChannel.Template, stopped);
AppendBiz(BizChannel.DepartmentalSync, stopped);
AppendBiz(BizChannel.Unreachable, stopped);
}
public void WriteBizDatabaseFailed(string mysqlErrorCode, string friendlyReason)
{
Biz(BizChannel.Attendance,
"Database connection failed.",
"",
"Reason :",
"",
string.IsNullOrWhiteSpace(mysqlErrorCode) ? "[MySQL Error]" : "[MySQL Error " + mysqlErrorCode + "]",
"",
string.IsNullOrWhiteSpace(friendlyReason) ? "Unable to connect to MySQL server." : friendlyReason,
"",
"Attendance synchronization cannot continue.",
"");
}
private void AppendBiz(BizChannel channel, string text)
{
string path;
switch (channel)
{
case BizChannel.Attendance:
path = Path.Combine(_attendanceBizDirectory, "attendance_logs_" + DateStamp() + ".txt");
break;
case BizChannel.UserSync:
path = Path.Combine(_userSyncBizDirectory, "user_sync_logs_" + DateStamp() + ".txt");
break;
case BizChannel.Template:
path = Path.Combine(_templateBizDirectory, "template_fetching_logs_" + DateStamp() + ".txt");
break;
case BizChannel.DepartmentalSync:
path = Path.Combine(_departmentalSyncBizDirectory, "departmental_sync_logs_" + DateStamp() + ".txt");
break;
case BizChannel.Unreachable:
path = Path.Combine(_unreachableBizDirectory, "unreachable_devices_" + DateStamp() + ".txt");
break;
default:
path = InternalLogPath();
break;
}
lock (_sync)
{
File.AppendAllText(path, text, Encoding.UTF8);
}
}
private static string DateStamp() => DateTime.Now.ToString("yyyy-MM-dd");
private string InternalLogPath() =>
Path.Combine(_internalLogsDirectory, "internal_logs_" + DateStamp() + ".txt");
private void WriteInternal(string level, string message)
{
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
lock (_sync)
{
File.AppendAllText(InternalLogPath(), line);
}
}
private void WriteInternalDiag(string jobName, string level, string message)
{
var tag = (jobName ?? "diag").Trim().ToUpperInvariant();
if (tag.Length == 0) tag = "DIAG";
WriteInternal(level, "[DIAG][" + tag + "] " + message);
}
public void Dispose() { }
}
// Manager implementation is in a separate file below for readability.
}