368 lines
12 KiB
C#
368 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Runtime.Serialization;
|
|
using System.Runtime.Serialization.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.ServiceProcess;
|
|
|
|
namespace HikvisionAttendanceService;
|
|
|
|
public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
|
{
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _mainTask;
|
|
private HikvisionAttendanceManager? _manager;
|
|
|
|
public HikvisionAttendanceWindowsService()
|
|
{
|
|
ServiceName = "HikvisionAttendanceService";
|
|
CanStop = true;
|
|
CanPauseAndContinue = false;
|
|
AutoLog = false; // we use our own file logger
|
|
}
|
|
|
|
protected override void OnStart(string[] args)
|
|
{
|
|
_cts = new CancellationTokenSource();
|
|
Environment.CurrentDirectory = AppContext.BaseDirectory;
|
|
|
|
var baseDir = AppContext.BaseDirectory;
|
|
var configPath = Path.Combine(baseDir, "serviceconfig.json");
|
|
if (!File.Exists(configPath))
|
|
{
|
|
// Fallback to appsettings.json (developer convenience).
|
|
configPath = Path.Combine(baseDir, "appsettings.json");
|
|
}
|
|
|
|
var config = HikvisionServiceConfig.Load(configPath);
|
|
var logger = new FileLogger(config.LogDirectory);
|
|
|
|
_manager = new HikvisionAttendanceManager(config, logger);
|
|
_mainTask = _manager.RunAsync(_cts.Token);
|
|
}
|
|
|
|
protected override void OnStop()
|
|
{
|
|
if (_cts is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_cts.Cancel();
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
if (_mainTask is not null)
|
|
{
|
|
try { _mainTask.Wait(TimeSpan.FromSeconds(20)); } catch { /* ignore */ }
|
|
}
|
|
|
|
_manager?.Dispose();
|
|
_manager = null;
|
|
}
|
|
|
|
[DataContract]
|
|
internal sealed class HikvisionServiceConfig
|
|
{
|
|
[DataMember]
|
|
public string LogDirectory { get; set; } = @"C:\SdkLog";
|
|
|
|
[DataMember]
|
|
public string SqlConnectionString { 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;
|
|
|
|
public static HikvisionServiceConfig Load(string configPath)
|
|
{
|
|
// Minimal JSON config loader (no external packages).
|
|
try
|
|
{
|
|
if (!File.Exists(configPath))
|
|
{
|
|
return new HikvisionServiceConfig
|
|
{
|
|
LogDirectory = @"C:\SdkLog",
|
|
SqlConnectionString = "",
|
|
EnableDatabasePersistence = false,
|
|
AttendanceTextFilePath = "",
|
|
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
|
|
HrExportPath = "",
|
|
AutoDoorControlOnSuccess = true,
|
|
AutoDoorCloseDelaySeconds = 3,
|
|
AcsHistoryMajor = 0,
|
|
AcsHistoryMinor = 0,
|
|
HistoricalFetchIntervalMinutes = 0,
|
|
HistoricalFetchLookbackMinutes = 1440
|
|
};
|
|
}
|
|
|
|
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);
|
|
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);
|
|
NormalizeLoadedDeviceConfig(cfg);
|
|
return cfg;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return new HikvisionServiceConfig
|
|
{
|
|
LogDirectory = @"C:\SdkLog",
|
|
SqlConnectionString = "",
|
|
EnableDatabasePersistence = false,
|
|
AttendanceTextFilePath = "",
|
|
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
|
|
HrExportPath = "",
|
|
AutoDoorControlOnSuccess = true,
|
|
AutoDoorCloseDelaySeconds = 3,
|
|
AcsHistoryMajor = 0,
|
|
AcsHistoryMinor = 0,
|
|
HistoricalFetchIntervalMinutes = 0,
|
|
HistoricalFetchLookbackMinutes = 1440
|
|
};
|
|
}
|
|
}
|
|
|
|
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 NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg)
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
[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 object _sync = new();
|
|
|
|
public FileLogger(string directory)
|
|
{
|
|
_directory = directory;
|
|
Directory.CreateDirectory(_directory);
|
|
}
|
|
|
|
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}"));
|
|
|
|
private void Write(string level, string message)
|
|
{
|
|
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
|
|
var logName = "attendance_logs_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt";
|
|
var path = Path.Combine(_directory, logName);
|
|
lock (_sync)
|
|
{
|
|
File.AppendAllText(path, line);
|
|
}
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
|
|
// Manager implementation is in a separate file below for readability.
|
|
}
|
|
|