hikvision-sync-service/HikvisionAttendanceWindowsS...

383 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;
/// <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;
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,
UseIsapiHttpForUserInfo = true,
IsapiHttpPort = 8000
};
}
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,
UseIsapiHttpForUserInfo = true,
IsapiHttpPort = 80
};
}
}
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.
}