989 lines
40 KiB
C#
989 lines
40 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 -> 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 -> 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 <= 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>
|
|
/// Machine IPs whose attendance is always written to hrms.terry_attendance_log
|
|
/// (no employee/worker_type lookup). Compared to attendance event machine_ip.
|
|
/// </summary>
|
|
[DataMember]
|
|
public List<string> TerryAttendanceMachineIps { 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>(),
|
|
TerryAttendanceMachineIps = 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>(),
|
|
TerryAttendanceMachineIps = 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);
|
|
if (overlay.TerryAttendanceMachineIps != null)
|
|
cfg.TerryAttendanceMachineIps = new List<string>(overlay.TerryAttendanceMachineIps);
|
|
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();
|
|
cfg.TerryAttendanceMachineIps ??= new List<string>();
|
|
for (int i = 0; i < cfg.TerryAttendanceMachineIps.Count; i++)
|
|
cfg.TerryAttendanceMachineIps[i] = (cfg.TerryAttendanceMachineIps[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>();
|
|
|
|
/// <summary>
|
|
/// IPs whose punches always go to terry_attendance_log (no worker_type lookup).
|
|
/// </summary>
|
|
[DataMember]
|
|
public List<string> TerryAttendanceMachineIps { 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.
|
|
}
|
|
|