diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs
index b5e4ce6..f4aebfe 100644
--- a/HikvisionAttendanceWindowsService.cs
+++ b/HikvisionAttendanceWindowsService.cs
@@ -39,6 +39,8 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
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);
@@ -93,6 +95,11 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public bool EnableTemplateDbPersistence { get; set; }
+ /// When true, pull face templates from device and save to DB (DEVICE -> DATABASE). Prefer only one template direction enabled at a time.
+ [DataMember]
+ public bool EnableTemplateDeviceToDbSync { get; set; }
+
+ /// When true, push face templates from DB to device (DATABASE -> DEVICE). Prefer only one template direction enabled at a time.
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
@@ -193,14 +200,29 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public int TemplateFetchIntervalHours { get; set; } = 4;
+ [DataMember]
+ public string MachineScopeMode { get; set; } = "CENTRAL";
+
+ [DataMember]
+ public List ScopedMachineIps { get; set; } = new List();
+
/// DeviceId (from ) to treat as the authoritative user source.
[DataMember]
public string SourceDeviceId { get; set; } = "";
+ [DataMember]
+ public string SourceMachineIp { get; set; } = "";
+
/// DeviceIds to push missing users/faces onto.
[DataMember]
public List TargetDeviceIds { get; set; } = new List();
+ [DataMember]
+ public List TargetMachineIps { get; set; } = new List();
+
+ [DataMember]
+ public List SyncEmployeeIds { 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; } = "";
@@ -224,6 +246,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
+ EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
@@ -249,10 +272,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
IsapiHttpPort = 8000,
EnableTemplateFetch = false,
TemplateFetchIntervalHours = 4,
+ MachineScopeMode = "CENTRAL",
+ ScopedMachineIps = new List(),
EnableUserSync = false,
SyncIntervalMinutes = 60,
SourceDeviceId = "",
+ SourceMachineIp = "",
TargetDeviceIds = new List(),
+ TargetMachineIps = new List(),
+ SyncEmployeeIds = new List(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@@ -296,6 +324,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
+ EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
@@ -321,10 +350,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
IsapiHttpPort = 80,
EnableTemplateFetch = false,
TemplateFetchIntervalHours = 4,
+ MachineScopeMode = "CENTRAL",
+ ScopedMachineIps = new List(),
EnableUserSync = false,
SyncIntervalMinutes = 60,
SourceDeviceId = "",
+ SourceMachineIp = "",
TargetDeviceIds = new List(),
+ TargetMachineIps = new List(),
+ SyncEmployeeIds = new List(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@@ -452,11 +486,18 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
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();
+ for (int i = 0; i < cfg.ScopedMachineIps.Count; i++)
+ cfg.ScopedMachineIps[i] = (cfg.ScopedMachineIps[i] ?? "").Trim();
if (cfg.TargetDeviceIds == null)
cfg.TargetDeviceIds = new List();
else
@@ -464,6 +505,12 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
for (int i = 0; i < cfg.TargetDeviceIds.Count; i++)
cfg.TargetDeviceIds[i] = DeviceIdentity.NormalizeConfigured(cfg.TargetDeviceIds[i]);
}
+ cfg.TargetMachineIps ??= new List();
+ for (int i = 0; i < cfg.TargetMachineIps.Count; i++)
+ cfg.TargetMachineIps[i] = (cfg.TargetMachineIps[i] ?? "").Trim();
+ cfg.SyncEmployeeIds ??= new List();
+ for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
+ cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim();
if (cfg.Devices == null)
return;
@@ -556,10 +603,12 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
{
private readonly string _directory;
private readonly string _internalLogsDirectory;
- private readonly string _attendanceJobLogsDirectory;
- private readonly string _templateJobLogsDirectory;
- private readonly string _userSyncJobLogsDirectory;
+ private readonly string _attendanceBizDirectory;
+ private readonly string _templateBizDirectory;
+ private readonly string _userSyncBizDirectory;
+ private readonly string _unreachableBizDirectory;
private readonly object _sync = new();
+ public readonly BizSessionTotals Totals = new BizSessionTotals();
public FileLogger(string directory)
{
@@ -567,69 +616,163 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
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");
+ _attendanceBizDirectory = Path.Combine(logsRoot, "attendance_logs");
+ _templateBizDirectory = Path.Combine(logsRoot, "template_fetching_logs");
+ _userSyncBizDirectory = Path.Combine(logsRoot, "user_sync_logs");
+ _unreachableBizDirectory = Path.Combine(_internalLogsDirectory, "unreachable_devices");
Directory.CreateDirectory(_internalLogsDirectory);
- Directory.CreateDirectory(_attendanceJobLogsDirectory);
- Directory.CreateDirectory(_templateJobLogsDirectory);
- Directory.CreateDirectory(_userSyncJobLogsDirectory);
+ Directory.CreateDirectory(_attendanceBizDirectory);
+ Directory.CreateDirectory(_templateBizDirectory);
+ Directory.CreateDirectory(_userSyncBizDirectory);
+ Directory.CreateDirectory(_unreachableBizDirectory);
}
- 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);
+ public string LogDirectory => _directory;
- private void Write(string level, string message)
+ 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}"));
+
+ /// Technical diagnostics only — never written to business logs/.
+ 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)
{
- 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);
+ if (lines == null || lines.Length == 0)
+ return;
lock (_sync)
{
- File.AppendAllText(path, line);
+ 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());
}
}
- private void WriteJob(string jobName, string level, string message)
+ // ---- Business / operations logs (human-readable, no SDK) ----
+
+ public void Biz(BizChannel channel, params string[] lines)
{
- string dir;
- string prefix;
- switch ((jobName ?? "").Trim().ToLowerInvariant())
+ 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.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.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 "attendance":
- case "attendance_logs":
- dir = _attendanceJobLogsDirectory;
- prefix = "attendance_logs_";
+ case BizChannel.Attendance:
+ path = Path.Combine(_attendanceBizDirectory, "attendance_logs_" + DateStamp() + ".txt");
break;
- case "template":
- case "template_fetch":
- case "template_fetching_logs":
- dir = _templateJobLogsDirectory;
- prefix = "template_fetching_logs_";
+ case BizChannel.UserSync:
+ path = Path.Combine(_userSyncBizDirectory, "user_sync_logs_" + DateStamp() + ".txt");
break;
- case "user_sync":
- case "user_sync_logs":
- dir = _userSyncJobLogsDirectory;
- prefix = "user_sync_logs_";
+ case BizChannel.Template:
+ path = Path.Combine(_templateBizDirectory, "template_fetching_logs_" + DateStamp() + ".txt");
+ break;
+ case BizChannel.Unreachable:
+ path = Path.Combine(_unreachableBizDirectory, "unreachable_devices_" + DateStamp() + ".txt");
break;
default:
- dir = _internalLogsDirectory;
- prefix = "internal_logs_";
+ path = InternalLogPath();
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);
+ 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() { }
}