hikvision-sync-service/HikvisionAttendanceManager.cs

5558 lines
226 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Data.SqlClient;
using System.Text;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using EventByDeploy;
using Common;
using HikvisionAttendanceService.Interop;
using HikvisionAttendanceService.Data;
using CHCNetSDK = EventByDeploy.CHCNetSDK;
namespace HikvisionAttendanceService;
internal sealed partial class HikvisionAttendanceManager : IDisposable
{
private const string BuildMarker = "CFGDIAG_20260331_1";
private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config;
private readonly HikvisionAttendanceWindowsService.FileLogger _logger;
private readonly ConcurrentQueue<AttendanceEvent> _queue = new ConcurrentQueue<AttendanceEvent>();
private readonly SemaphoreSlim _queueSignal = new SemaphoreSlim(0, int.MaxValue);
private readonly int _queueMax = 1024;
private int _queueSize;
private readonly object _csvWriteLock = new object();
private readonly object _attendanceTextLock = new object();
private readonly string _csvPath;
private readonly string _exportPath;
private readonly string _hrExportPath;
private readonly string _attendanceTextPath;
private readonly List<DeviceSession> _sessions = new List<DeviceSession>();
private readonly List<HikvisionAttendanceWindowsService.DeviceConfig> _runtimeDevices = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
private readonly IHrmsDbConnectionFactory? _dbConnectionFactory;
private readonly IAttendanceMachineRepository? _attendanceMachineRepository;
private readonly IAttendanceLogRepository? _attendanceLogRepository;
private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository;
private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository;
private CancellationTokenSource _cts;
private Task _queueWriterTask;
private Task _exportTask;
private Common.CHCNetSDK.MSGCallBack _callbackDelegate;
private readonly ConcurrentDictionary<string, byte> _dedupeKeys = new ConcurrentDictionary<string, byte>();
private const int DedupeMaxEntries = 50_000;
public HikvisionAttendanceManager(
HikvisionAttendanceWindowsService.HikvisionServiceConfig config,
HikvisionAttendanceWindowsService.FileLogger logger)
{
_config = config;
_logger = logger;
Directory.CreateDirectory(_config.LogDirectory);
_csvPath = Path.Combine(_config.LogDirectory, "attendance_events.csv");
_exportPath = Path.Combine(_config.LogDirectory, "attendance_export.csv");
_hrExportPath = string.IsNullOrWhiteSpace(_config.HrExportPath)
? Path.Combine(_config.LogDirectory, "attendance_hr_sync.csv")
: _config.HrExportPath;
_attendanceTextPath = string.IsNullOrWhiteSpace(_config.AttendanceTextFilePath)
? Path.Combine(_config.LogDirectory, "attendance_records.txt")
: _config.AttendanceTextFilePath.Trim();
var textDir = Path.GetDirectoryName(Path.GetFullPath(_attendanceTextPath));
if (!string.IsNullOrEmpty(textDir))
Directory.CreateDirectory(textDir);
if (_config.EnableDbIntegration)
{
_dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config);
_attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory);
_attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory);
_attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory);
_attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory);
_logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked());
}
EnsureCsvSchema();
}
private void EnsureCsvSchema()
{
const string header =
"DeviceId,DeviceIp,Timestamp,EmployeeNo,UserIdentifier,CardNo,DoorNo,ReaderNo,Method,EventName,EventType,Source,IsSuccess,RawMajor,RawMinor";
if (!File.Exists(_csvPath))
{
File.WriteAllText(_csvPath, header + Environment.NewLine);
return;
}
try
{
var first = File.ReadLines(_csvPath).FirstOrDefault() ?? "";
if (first.IndexOf("ReaderNo", StringComparison.OrdinalIgnoreCase) < 0 ||
first.IndexOf("Source", StringComparison.OrdinalIgnoreCase) < 0)
{
var bak = _csvPath + ".legacy_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".bak";
File.Copy(_csvPath, bak, overwrite: true);
File.WriteAllText(_csvPath, header + Environment.NewLine);
_logger.Warn("attendance CSV schema upgraded; previous file copied to " + bak);
}
}
catch (Exception ex)
{
_logger.Error("EnsureCsvSchema failed", ex);
}
}
private List<HikvisionAttendanceWindowsService.DeviceConfig> ResolveRuntimeDevices()
{
_runtimeDevices.Clear();
bool loadedFromDb = false;
if (_config.EnableDbIntegration && _config.EnableDbMachineLoading && _attendanceMachineRepository != null)
{
var dbMachines = _attendanceMachineRepository.GetActiveMachines("HIKVISION", out var dbErr);
if (!string.IsNullOrWhiteSpace(dbErr))
_logger.Warn("DB machine loading failed; fallback to config. err=" + dbErr);
else
{
foreach (var m in dbMachines)
{
var matchingCfg = (_config.Devices ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>())
.FirstOrDefault(x =>
string.Equals(x.Ip, m.MachineIp, StringComparison.OrdinalIgnoreCase) ||
string.Equals(DeviceIdentity.CanonicalLookupKey(x.DeviceId), DeviceIdentity.CanonicalLookupKey(m.MachineId), StringComparison.Ordinal));
if (matchingCfg == null)
continue;
_runtimeDevices.Add(new HikvisionAttendanceWindowsService.DeviceConfig
{
DeviceId = string.IsNullOrWhiteSpace(m.MachineId) ? matchingCfg.DeviceId : m.MachineId,
Ip = m.MachineIp,
Port = m.PortNumber > 0 ? m.PortNumber : matchingCfg.Port,
Username = matchingCfg.Username,
Password = matchingCfg.Password,
FingerPrintReaderNo = matchingCfg.FingerPrintReaderNo,
FaceReaderNo = matchingCfg.FaceReaderNo,
GatewayDoorIndex = matchingCfg.GatewayDoorIndex,
Model = matchingCfg.Model,
SerialNumber = matchingCfg.SerialNumber,
FirmwareVersion = matchingCfg.FirmwareVersion,
SubnetMask = matchingCfg.SubnetMask,
DefaultGateway = matchingCfg.DefaultGateway
});
}
loadedFromDb = _runtimeDevices.Count > 0;
_logger.Info("DB machine loading: loaded " + _runtimeDevices.Count + " active Hikvision machines.");
}
}
if (!_config.PreferDbMachinesOverConfig || !loadedFromDb)
return _config.Devices ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
return _runtimeDevices;
}
/// <summary>Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch.</summary>
public async Task RunAsync(CancellationToken token)
{
Environment.CurrentDirectory = AppContext.BaseDirectory;
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
_queueWriterTask = Task.Run(() => QueueWriterLoop(_cts.Token), _cts.Token);
_exportTask = Task.Run(() => ExportLoop(_cts.Token), _cts.Token);
try
{
if (!Common.CHCNetSDK.NET_DVR_Init())
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
LogSdkFailure("NET_DVR_Init", err, null, null, "SDK initialization failed; service startup will stop.", isWarning: false);
return;
}
_logger.Info("NET_DVR_Init succeeded.");
_logger.Info("HCNetSDK routing: all native entry points that must share init state use Common.CHCNetSDK (DllImport HCNetSDK\\HCNetSDK.dll next to exe). " +
"EventByDeploy types are used only for ACS struct layouts and TypeMap (no separate DllImport calls for callback/alarm/remote-config).");
_logger.Info("Attendance persistence: plain-text file=\"" + _attendanceTextPath + "\", database INSERTs " +
(_config.EnableDatabasePersistence && !string.IsNullOrWhiteSpace(_config.SqlConnectionString)
? "ENABLED"
: "DISABLED (set EnableDatabasePersistence true + SqlConnectionString to re-enable)"));
StartDevices();
_logger.Info("Startup: active device sessions=" + _sessions.Count + ". Historical ACS query API: NET_DVR_GET_ACS_EVENT is available.");
var attendanceInterval = _config.AttendanceSyncIntervalMinutes > 0
? _config.AttendanceSyncIntervalMinutes
: _config.HistoricalFetchIntervalMinutes;
if (_config.EnableAttendanceSync && attendanceInterval > 0 && _sessions.Count > 0)
{
_logger.Info("Scheduled historical fetch enabled: every " + attendanceInterval +
" min, lookback " + _config.HistoricalFetchLookbackMinutes + " min.");
_logger.JobInfo("attendance", "Scheduler enabled intervalMinutes=" + attendanceInterval +
" lookbackMinutes=" + _config.HistoricalFetchLookbackMinutes + " activeDevices=" + _sessions.Count);
_ = Task.Run(() => HistoricalSchedulerLoop(_cts.Token), _cts.Token);
}
if (_config.EnableTemplateFetch &&
_config.TemplateFetchIntervalHours > 0 &&
_sessions.Count > 0)
{
_logger.Info("Template fetch scheduler enabled: every " + _config.TemplateFetchIntervalHours +
" hour(s), active devices=" + _sessions.Count + ".");
_logger.JobInfo("template_fetch", "Scheduler enabled intervalHours=" + _config.TemplateFetchIntervalHours +
" activeDevices=" + _sessions.Count);
_ = Task.Run(() => TemplateFetchSchedulerLoop(_cts.Token), _cts.Token);
}
if (_config.EnableUserSync &&
_config.SyncIntervalMinutes > 0 &&
!string.IsNullOrWhiteSpace(_config.SourceDeviceId) &&
_config.TargetDeviceIds != null &&
_config.TargetDeviceIds.Count > 0)
{
_logger.Info("User sync scheduler enabled: every " + _config.SyncIntervalMinutes + " min, source=\"" +
_config.SourceDeviceId + "\", targetCount=" + _config.TargetDeviceIds.Count + ", isapiHttpPort=" +
_config.IsapiHttpPort + ".");
_logger.JobInfo("user_sync", "Scheduler enabled intervalMinutes=" + _config.SyncIntervalMinutes +
" source=" + _config.SourceDeviceId + " targetCount=" + _config.TargetDeviceIds.Count);
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
}
await Task.Delay(Timeout.Infinite, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// shutdown
}
catch (Exception ex)
{
_logger.Error("RunAsync main loop crashed", ex);
}
finally
{
StopDevices();
try { Common.CHCNetSDK.NET_DVR_Cleanup(); } catch { /* ignore */ }
_logger.Info("NET_DVR_Cleanup completed.");
}
}
/// <summary>Fetches stored ACS events from the device for the given local time range and enqueues them into the same pipeline.</summary>
public Task<int> FetchAttendanceRecordsAsync(string deviceId, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken = default)
{
return Task.Run(() =>
{
DateTime? lastEventTimestamp;
bool dbInsertAllSucceeded;
int n = FetchAttendanceRecordsCore(deviceId, fromLocal, toLocal, cancellationToken, out lastEventTimestamp, out dbInsertAllSucceeded);
// CLI/manual fetches should also advance the last-sync cursor,
// otherwise incremental sync won't work until the scheduled loop runs.
var lastSyncStore = (_config.EnableDbIntegration && _attendanceMachineRepository != null)
? "db.attendance_machine.last_sync_date"
: "file." + GetLastSyncFilePath(deviceId);
_logger.Info("LastSync write check (CLI/manual fetch): device=" + deviceId + ", n=" + n +
", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)") +
", store=" + lastSyncStore);
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value);
_logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId +
", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss"));
}
else if (n == 0 && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(deviceId, toLocal);
_logger.Info("No records found; advancing last_sync_date to window end. machine=" + deviceId +
" timestamp=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
}
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
{
_logger.Warn("LastSync NOT updated (CLI/manual fetch): DB insert did not fully succeed; will retry same window.");
_logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed.");
}
_logger.JobInfo("attendance", "MACHINE " + deviceId + " has " + n + " records !");
return n;
}, cancellationToken);
}
public bool OpenDoor(string deviceId, out string error)
{
return ControlDoor(deviceId, 1, out error);
}
public bool CloseDoor(string deviceId, out string error)
{
return ControlDoor(deviceId, 0, out error);
}
public bool StayOpen(string deviceId, out string error)
{
return ControlDoor(deviceId, 2, out error);
}
public bool StayClose(string deviceId, out string error)
{
return ControlDoor(deviceId, 3, out error);
}
public bool SetFaceTemplate(string deviceId, string cardNo, int readerNo, byte[] faceImageBytes, out string error)
{
error = "";
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
if (string.IsNullOrWhiteSpace(cardNo))
{
error = "cardNo is required";
return false;
}
if (faceImageBytes == null || faceImageBytes.Length == 0)
{
error = "faceImageBytes is empty";
return false;
}
if (faceImageBytes.Length > 200 * 1024)
{
error = "face image exceeds 200KB";
return false;
}
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
if (readerNo <= 0)
readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
int handle = -1;
IntPtr condPtr = IntPtr.Zero;
IntPtr inPtr = IntPtr.Zero;
IntPtr outPtr = IntPtr.Zero;
IntPtr facePtr = IntPtr.Zero;
try
{
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_COND();
cond.Init();
cond.dwSize = (uint)Marshal.SizeOf(cond);
cond.dwFaceNum = 1;
cond.dwEnableReaderNo = (uint)readerNo;
CopyUtf8(cardNo, cond.byCardNo);
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
Marshal.StructureToPtr(cond, condPtr, false);
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
session.UserId,
(uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FACE,
condPtr,
(int)cond.dwSize,
null,
IntPtr.Zero);
if (handle < 0)
{
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FACE) failed, err=" + sdkErr;
_logger.Error(error);
return false;
}
var record = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_RECORD();
record.Init();
record.dwSize = (uint)Marshal.SizeOf(record);
CopyUtf8(cardNo, record.byCardNo);
record.dwFaceLen = (uint)faceImageBytes.Length;
facePtr = Marshal.AllocHGlobal(faceImageBytes.Length);
Marshal.Copy(faceImageBytes, 0, facePtr, faceImageBytes.Length);
record.pFaceBuffer = facePtr;
var status = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_STATUS();
status.Init();
status.dwSize = (uint)Marshal.SizeOf(status);
inPtr = Marshal.AllocHGlobal((int)record.dwSize);
outPtr = Marshal.AllocHGlobal((int)status.dwSize);
uint outLen = 0;
int attempts = 0;
bool accepted = false;
while (attempts++ < 300)
{
Marshal.StructureToPtr(record, inPtr, false);
Marshal.StructureToPtr(status, outPtr, false);
int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig(
handle,
inPtr,
(uint)record.dwSize,
outPtr,
(uint)status.dwSize,
ref outLen);
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT)
{
Thread.Sleep(50);
continue;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH)
{
if (accepted)
return true;
error = "face config finished before success status";
return false;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS)
{
status = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FACE_STATUS>(outPtr);
if (status.byRecvStatus == 1)
{
accepted = true;
continue;
}
string msg = DecodeCardNo(status.byErrorMsg);
error = "face template rejected, recvStatus=" + status.byRecvStatus + ", readerNo=" + status.dwReaderNo + ", msg=" + msg;
_logger.Warn(error);
return false;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED ||
rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION)
{
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
error = "face template remote config failed, rc=" + rc + ", err=" + sdkErr;
_logger.Error(error);
return false;
}
}
error = "face template timed out waiting for device response";
return false;
}
catch (Exception ex)
{
error = "SetFaceTemplate exception: " + ex.Message;
_logger.Error(error, ex);
return false;
}
finally
{
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
try { if (facePtr != IntPtr.Zero) Marshal.FreeHGlobal(facePtr); } catch { /* ignore */ }
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ }
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
}
}
public bool GetFaceTemplate(string deviceId, string cardNo, out byte[] faceImageBytes, out string error)
{
error = "";
faceImageBytes = Array.Empty<byte>();
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
bool ok = HikvisionTemplateInterop.GetFaceParam(session.UserId, channel, cardNo, out faceImageBytes);
if (!ok)
{
error = "GetFaceTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FACE_PARAM_CFG)");
_logger.Error(error);
}
return ok;
}
public bool DeleteFaceTemplate(string deviceId, string cardNo, out string error)
{
error = "";
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
bool ok = HikvisionTemplateInterop.DeleteFaceParam(session.UserId, channel, cardNo);
if (!ok)
{
error = "DeleteFaceTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FACE_PARAM_CFG)");
_logger.Error(error);
}
return ok;
}
public bool SetFingerprintTemplate(string deviceId, string cardNo, int readerNo, byte fingerId, byte[] fingerprintData, out string error)
{
error = "";
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
if (string.IsNullOrWhiteSpace(cardNo))
{
error = "cardNo is required";
return false;
}
if (fingerprintData == null || fingerprintData.Length == 0)
{
error = "fingerprintData is empty";
return false;
}
if (fingerprintData.Length > EventByDeploy.CHCNetSDK.MAX_FINGER_PRINT_LEN)
{
error = "fingerprintData exceeds MAX_FINGER_PRINT_LEN";
return false;
}
if (fingerId == 0 || fingerId > 10)
{
error = "fingerId must be 1..10";
return false;
}
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
if (readerNo <= 0)
readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
int handle = -1;
IntPtr condPtr = IntPtr.Zero;
IntPtr inPtr = IntPtr.Zero;
IntPtr outPtr = IntPtr.Zero;
try
{
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_COND();
cond.Init();
cond.dwSize = (uint)Marshal.SizeOf(cond);
cond.dwFingerPrintNum = 1;
cond.dwEnableReaderNo = (uint)readerNo;
cond.byFingerPrintID = fingerId;
CopyUtf8(cardNo, cond.byCardNo);
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
Marshal.StructureToPtr(cond, condPtr, false);
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
session.UserId,
(uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FINGERPRINT,
condPtr,
(int)cond.dwSize,
null,
IntPtr.Zero);
if (handle < 0)
{
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FINGERPRINT) failed, err=" + sdkErr;
_logger.Error(error);
return false;
}
var record = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_RECORD();
record.Init();
record.dwSize = (uint)Marshal.SizeOf(record);
CopyUtf8(cardNo, record.byCardNo);
record.dwEnableReaderNo = (uint)readerNo;
record.byFingerPrintID = fingerId;
record.byFingerType = 0;
record.dwFingerPrintLen = (uint)fingerprintData.Length;
Buffer.BlockCopy(fingerprintData, 0, record.byFingerData, 0, fingerprintData.Length);
var status = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_STATUS();
status.Init();
status.dwSize = (uint)Marshal.SizeOf(status);
inPtr = Marshal.AllocHGlobal((int)record.dwSize);
outPtr = Marshal.AllocHGlobal((int)status.dwSize);
uint outLen = 0;
int attempts = 0;
bool accepted = false;
while (attempts++ < 300)
{
Marshal.StructureToPtr(record, inPtr, false);
Marshal.StructureToPtr(status, outPtr, false);
int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig(
handle,
inPtr,
(uint)record.dwSize,
outPtr,
(uint)status.dwSize,
ref outLen);
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT)
{
Thread.Sleep(50);
continue;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH)
{
if (accepted)
return true;
error = "fingerprint config finished before success status";
return false;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS)
{
status = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_STATUS>(outPtr);
if (status.byRecvStatus == 0)
{
accepted = true;
continue;
}
string msg = DecodeCardNo(status.byErrorMsg);
error = "fingerprint template rejected, recvStatus=" + status.byRecvStatus +
", readerRecvStatus=" + status.byCardReaderRecvStatus +
", cardReaderNo=" + status.dwCardReaderNo +
", msg=" + msg;
_logger.Warn(error);
return false;
}
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED ||
rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION)
{
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
error = "fingerprint template remote config failed, rc=" + rc + ", err=" + sdkErr;
_logger.Error(error);
return false;
}
}
error = "fingerprint template timed out waiting for device response";
return false;
}
catch (Exception ex)
{
error = "SetFingerprintTemplate exception: " + ex.Message;
_logger.Error(error, ex);
return false;
}
finally
{
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ }
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
}
}
public bool GetFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out byte[] fingerprintData, out string error)
{
error = "";
fingerprintData = Array.Empty<byte>();
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
bool ok = HikvisionTemplateInterop.GetFingerprintParam(session.UserId, channel, cardNo, fingerId, out fingerprintData);
if (!ok)
{
error = "GetFingerprintTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FINGERPRINT_PARAM)");
_logger.Error(error);
}
return ok;
}
/// <summary>
/// Exports face + fingerprint templates for one SDK user key (<paramref name="cardNo"/> is written to device as card / employee string).
/// Uses <c>NET_DVR_GetDeviceConfig</c> with <c>NET_DVR_GET_FACE_PARAM_CFG</c> and <c>NET_DVR_GET_FINGERPRINT_PARAM</c> (per-finger 1..10).
/// </summary>
private bool TryBuildUserTemplatesPayload(string deviceId, string cardNo, out UserTemplateExportPayload payload, out string error)
{
payload = new UserTemplateExportPayload();
error = "";
try
{
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
if (string.IsNullOrWhiteSpace(cardNo))
{
error = "cardNo is required (Hikvision enroll key; often the same string as employeeNo)";
return false;
}
if (FindSession(deviceId) == null)
{
error = "device not logged in: " + deviceId;
return false;
}
payload = new UserTemplateExportPayload
{
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
deviceId = deviceId.Trim(),
cardNo = cardNo.Trim()
};
var faceItem = new FaceTemplateExportItem { attempted = true };
if (GetFaceTemplate(deviceId, cardNo, out var faceBytes, out var faceErr))
{
if (faceBytes != null && faceBytes.Length > 0)
{
faceItem.present = true;
faceItem.byteLength = faceBytes.Length;
faceItem.dataBase64 = Convert.ToBase64String(faceBytes);
faceItem.error = "";
}
else
{
faceItem.present = false;
faceItem.error = string.IsNullOrEmpty(faceErr) ? "no face template on device (empty response)" : faceErr;
}
}
else
{
faceItem.present = false;
faceItem.error = string.IsNullOrEmpty(faceErr) ? "face query failed" : faceErr;
}
payload.face = faceItem;
for (byte fingerId = 1; fingerId <= 10; fingerId++)
{
var fpItem = new FingerprintTemplateExportItem { fingerId = fingerId, attempted = true };
if (GetFingerprintTemplate(deviceId, cardNo, fingerId, out var fpData, out var fpErr))
{
if (fpData != null && fpData.Length > 0)
{
fpItem.present = true;
fpItem.byteLength = fpData.Length;
fpItem.dataBase64 = Convert.ToBase64String(fpData);
fpItem.error = "";
}
else
{
fpItem.present = false;
fpItem.error = string.IsNullOrEmpty(fpErr) ? "slot empty" : fpErr;
}
}
else
{
fpItem.present = false;
fpItem.error = string.IsNullOrEmpty(fpErr) ? "fingerprint query failed" : fpErr;
}
payload.fingerprints.Add(fpItem);
}
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool TryExportUserTemplatesToFile(string deviceId, string cardNo, string? outputPath, out string writtenPath, out string error)
{
writtenPath = "";
error = "";
try
{
if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var buildErr))
{
error = buildErr;
return false;
}
var path = string.IsNullOrWhiteSpace(outputPath)
? Path.Combine(_config.LogDirectory, BuildDefaultTemplateExportFileName(deviceId, cardNo))
: outputPath.Trim();
var full = Path.GetFullPath(path);
var dir = Path.GetDirectoryName(full);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(payload);
File.WriteAllText(full, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
writtenPath = full;
_logger.Info("User template export completed: path=" + full + " device=" + deviceId + " cardNo=" + cardNo);
return true;
}
catch (Exception ex)
{
error = ex.Message;
_logger.Error("TryExportUserTemplatesToFile failed", ex);
return false;
}
}
public bool TryExportAllUsersTemplatesToFile(
string deviceId,
string? outDir,
int pageSize,
int maxUsers,
CancellationToken cancellationToken,
out string writtenJsonPath,
out string error)
{
writtenJsonPath = "";
error = "";
try
{
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
if (pageSize <= 0) pageSize = 30;
if (maxUsers <= 0) maxUsers = 10000;
var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim();
Directory.CreateDirectory(dir);
var jsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId));
writtenJsonPath = jsonPath;
var faceLogPath = Path.Combine(dir, "face_templates_log.txt");
var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt");
var templateJobLogsDir = Path.Combine(_config.LogDirectory, "logs", "template_fetching_logs");
Directory.CreateDirectory(templateJobLogsDir);
var templateFaceStatusLogPath = Path.Combine(templateJobLogsDir, "face_templates_fetch_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt");
var templateFingerStatusLogPath = Path.Combine(templateJobLogsDir, "finger_templates_fetch_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt");
var root = new AllUsersTemplateExportPayload
{
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
deviceId = deviceId.Trim(),
userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search"
};
var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken);
if (!string.IsNullOrEmpty(listErr))
root.listFetchError = listErr;
_logger.Info("ExportAllTemplates: device=" + deviceId + ", discoveredUsers=" + cardNos.Count +
", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr));
using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export face templates");
fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export fingerprint templates");
foreach (var cardNo in cardNos)
{
if (cancellationToken.IsCancellationRequested)
break;
if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var perErr))
{
// Keep going even if one card payload fails unexpectedly.
payload = new UserTemplateExportPayload
{
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
deviceId = deviceId.Trim(),
cardNo = cardNo
};
payload.face = new FaceTemplateExportItem { attempted = true, present = false, byteLength = 0, error = perErr };
payload.fingerprints = new List<FingerprintTemplateExportItem>();
}
root.users.Add(payload);
// Face log line (one per user)
var f = payload.face;
faceSw.WriteLine(DateTime.UtcNow.ToString("o") +
" device=" + deviceId +
" cardNo=" + cardNo +
" present=" + f.present +
" len=" + f.byteLength +
" error=" + (string.IsNullOrEmpty(f.error) ? "-" : f.error));
// Fingerprint log lines (one per finger slot)
foreach (var fp in payload.fingerprints)
{
fpSw.WriteLine(DateTime.UtcNow.ToString("o") +
" device=" + deviceId +
" cardNo=" + cardNo +
" fingerId=" + fp.fingerId +
" present=" + fp.present +
" len=" + fp.byteLength +
" error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error));
}
}
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root);
File.WriteAllText(jsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
_logger.Info("ExportAllTemplates: wrote json path=" + jsonPath + ", users=" + root.users.Count);
return true;
}
catch (OperationCanceledException)
{
error = "cancelled";
return false;
}
catch (Exception ex)
{
error = ex.Message;
_logger.Error("TryExportAllUsersTemplatesToFile failed", ex);
return false;
}
}
private static string BuildAllUsersTemplatesExportFileName(string deviceId)
{
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
if (string.IsNullOrEmpty(key))
key = (deviceId ?? "").Trim();
foreach (var ch in Path.GetInvalidFileNameChars())
key = key.Replace(ch, '_');
return "all_user_templates_" + key + "_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json";
}
private List<string> FetchAllUserCardNosStdXml(
int sessionUserId,
int pageSize,
int maxUsers,
out string listError,
CancellationToken cancellationToken)
{
listError = "";
var all = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Best-effort pagination. If response doesn't include InfoList, we try alternative wrapper keys.
string searchId = "1";
int offset = 0;
const int maxPages = 2000; // guardrails
for (int page = 0; page < maxPages && all.Count < maxUsers; page++)
{
if (cancellationToken.IsCancellationRequested)
break;
_logger.Info("ExportAllTemplates: user-list page=" + page + ", offset=" + offset + ", maxUsers=" + maxUsers);
// Try wrapper key #1
string body1 = BuildJsonUserInfoSearchCond("UserInfoSearchCond", searchId, offset, pageSize);
string raw1 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var sdkErr1);
var pageNos = ExtractUserCardNosFromStdXml(raw1);
if (pageNos.Count == 0)
{
// Try wrapper key #2
string body2 = BuildJsonUserInfoSearchCond("AcsUserInfoCond", searchId, offset, pageSize);
string raw2 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var sdkErr2);
pageNos = ExtractUserCardNosFromStdXml(raw2);
if (pageNos.Count == 0)
{
listError = "user-list fetch returned no employee/card keys. sdkErr1=" + sdkErr1 + ", sdkErr2=" + sdkErr2;
var snippet1 = raw1.Length > 800 ? raw1.Substring(0, 800) : raw1;
var snippet2 = raw2.Length > 800 ? raw2.Substring(0, 800) : raw2;
_logger.Warn("ExportAllTemplates: user-list parse empty; stopping. offset=" + offset + ", listError=" + listError +
", raw1_snip=\"" + snippet1.Replace("\n", " ").Replace("\r", " ") + "\"" +
", raw2_snip=\"" + snippet2.Replace("\n", " ").Replace("\r", " ") + "\"");
break;
}
}
foreach (var c in pageNos)
{
if (string.IsNullOrWhiteSpace(c))
continue;
all.Add(c.Trim());
if (all.Count >= maxUsers)
break;
}
if (pageNos.Count < pageSize)
break; // probably end
offset += pageSize;
}
return all.ToList();
}
private static string BuildJsonUserInfoSearchCond(string wrapperKey, string searchId, int startOffset, int maxResults)
{
return "{ \"" + wrapperKey + "\": { " +
"\"searchID\": \"" + EscapeJsonStatic(searchId) + "\"," +
"\"searchResultPosition\": " + startOffset + "," +
"\"maxResults\": " + maxResults +
" } }";
}
private static string EscapeJsonStatic(string value)
{
if (value == null) return "";
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "\\r")
.Replace("\n", "\\n")
.Replace("\t", "\\t");
}
private static List<string> ExtractUserCardNosFromStdXml(string responseJson)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(responseJson))
return result;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(responseJson);
if (root == null)
return result;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var keySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"employeeNo",
"employeeNoString",
"cardNo",
"cardNoString",
"employeeId",
"userId"
};
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (keySet.Contains(kv.Key) && kv.Value != null)
{
string? s = kv.Value is string ss ? ss : kv.Value.ToString();
if (!string.IsNullOrWhiteSpace(s))
{
s = s.Trim();
// Avoid grabbing non-IDs (heuristic: require at least 1 digit and max len)
if (s.Length <= 64 && s.Any(char.IsDigit))
{
if (seen.Add(s))
result.Add(s);
}
}
}
if (kv.Value != null)
stack.Push(kv.Value);
}
}
else if (cur is object[] arr)
{
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
}
catch
{
// Best-effort only.
}
return result;
}
private static string BuildDefaultTemplateExportFileName(string deviceId, string cardNo)
{
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
if (string.IsNullOrEmpty(key))
key = (deviceId ?? "").Trim();
foreach (var ch in Path.GetInvalidFileNameChars())
key = key.Replace(ch, '_');
var safeCard = cardNo ?? "";
foreach (var ch in Path.GetInvalidFileNameChars())
safeCard = safeCard.Replace(ch, '_');
return "user_templates_" + key + "_" + safeCard + "_" +
DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json";
}
public bool DeleteFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out string error)
{
error = "";
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
bool ok = HikvisionTemplateInterop.DeleteFingerprintParam(session.UserId, channel, cardNo, fingerId);
if (!ok)
{
error = "DeleteFingerprintTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FINGERPRINT_PARAM)");
_logger.Error(error);
}
return ok;
}
public bool SyncTemplatesToDevice(
string deviceId,
string cardNo,
int faceReaderNo,
byte[] faceImageBytes,
int fingerprintReaderNo,
byte fingerId,
byte[] fingerprintData,
out string error)
{
error = "";
if (faceImageBytes != null && faceImageBytes.Length > 0)
{
if (!SetFaceTemplate(deviceId, cardNo, faceReaderNo, faceImageBytes, out error))
return false;
}
if (fingerprintData != null && fingerprintData.Length > 0)
{
if (!SetFingerprintTemplate(deviceId, cardNo, fingerprintReaderNo, fingerId, fingerprintData, out error))
return false;
}
return true;
}
private void StartDevices()
{
// Callback MUST use the same native module as NET_DVR_Init (Common). EventByDeploy uses a different DllImport path → second copy → err=3 NET_DVR_NOINIT.
if (_callbackDelegate == null)
{
_logger.Info("NET_DVR_SetDVRMessageCallBack_V50: registering via Common.CHCNetSDK (before per-device login).");
_callbackDelegate = new Common.CHCNetSDK.MSGCallBack(AlarmCallback);
bool cbOk = Common.CHCNetSDK.NET_DVR_SetDVRMessageCallBack_V50(0, _callbackDelegate, IntPtr.Zero);
if (!cbOk)
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
LogSdkFailure("NET_DVR_SetDVRMessageCallBack_V50", err, null, null,
"Failed to register alarm callback delegate (Common SDK module).", isWarning: false);
}
else
{
_logger.Info("NET_DVR_SetDVRMessageCallBack_V50 succeeded (Common), index=0, delegate=MSGCallBack. build=" + BuildMarker);
}
}
var devicesToLogin = ResolveRuntimeDevices();
// Startup diagnostics: prove whether we have devices to login.
if (devicesToLogin == null)
{
_logger.Warn("StartDevices: config.Devices is NULL; skipping all device logins (active sessions will remain 0).");
return;
}
_logger.Info("StartDevices: devicesToLoginCount=" + devicesToLogin.Count);
if (devicesToLogin.Count == 0)
{
_logger.Warn("StartDevices: Devices is empty; skipping all device logins (active sessions will remain 0).");
return;
}
for (int i = 0; i < devicesToLogin.Count; i++)
{
var d = devicesToLogin[i];
_logger.Info("StartDevices: loadedDevice[" + i + "]: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port +
" Username=\"" + (d.Username ?? "") + "\"");
}
foreach (var device in devicesToLogin)
{
_logger.Info("NET_DVR_Login_V30: connecting " + device.Ip + ":" + device.Port + " user=" + device.Username + " (" + device.DeviceId + ")...");
var deviceInfo = new Common.CHCNetSDK.NET_DVR_DEVICEINFO_V30();
int userId = Common.CHCNetSDK.NET_DVR_Login_V30(
device.Ip,
device.Port,
device.Username,
device.Password,
ref deviceInfo);
if (userId < 0)
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
LogSdkFailure("NET_DVR_Login_V30", err, device.DeviceId, device.Ip,
"Device login failed on port " + device.Port + ". Check device IP/network/credentials.", isWarning: false);
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null);
continue;
}
_logger.Info("NET_DVR_Login_V30 succeeded for " + device.DeviceId + " (" + device.Ip + ":" + device.Port + "), userId=" + userId + ". SDK serial=" +
FormatSdkSerial(deviceInfo.sSerialNumber) + ", wDevType=0x" + deviceInfo.wDevType.ToString("X4") + ".");
LogConfiguredTerminalProfile(device);
int alarmHandle = -1;
var alarmParam = new Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50
{
byLevel = 1,
byAlarmInfoType = 1,
byRetAlarmTypeV40 = 0,
byRetDevInfoVersion = 0,
byRetVQDAlarmType = 0,
byFaceAlarmDetection = 0,
bySupport = 0,
byBrokenNetHttp = 0,
wTaskNo = 0,
byDeployType = 1, // real-time deploy
byRes1 = new byte[3],
byAlarmTypeURL = 0,
byCustomCtrl = 0,
byRes4 = new byte[128]
};
alarmParam.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50));
_logger.Info("NET_DVR_SetupAlarmChan_V50: userId=" + userId + " dwSize=" + alarmParam.dwSize + " byLevel=" + alarmParam.byLevel +
" byAlarmInfoType=" + alarmParam.byAlarmInfoType + " byDeployType=" + alarmParam.byDeployType + " (Common).");
alarmHandle = Common.CHCNetSDK.NET_DVR_SetupAlarmChan_V50(userId, ref alarmParam, IntPtr.Zero, 0);
if (alarmHandle < 0)
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
LogSdkFailure("NET_DVR_SetupAlarmChan_V50", err, device.DeviceId, device.Ip,
"Alarm channel setup failed; session will still be kept for login-only APIs (historical fetch, template APIs).", isWarning: true);
}
else
{
_logger.Info("NET_DVR_SetupAlarmChan_V50 succeeded for " + device.DeviceId + ", alarmHandle=" + alarmHandle + ".");
}
_sessions.Add(new DeviceSession(device, userId, alarmHandle));
_logger.Info("Session REGISTERED: deviceId=" + device.DeviceId + " userId=" + userId + " alarmHandle=" + alarmHandle +
(alarmHandle < 0 ? " (no live alarm; fetch still allowed)" : ""));
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(device.Ip, "IDLE", null, null);
}
}
private void StopDevices()
{
foreach (var s in _sessions.ToArray())
{
try
{
if (s.AlarmHandle >= 0)
{
if (!Common.CHCNetSDK.NET_DVR_CloseAlarmChan_V30(s.AlarmHandle))
{
LogSdkFailure("NET_DVR_CloseAlarmChan_V30", Common.CHCNetSDK.NET_DVR_GetLastError(),
s.Device.DeviceId, s.Device.Ip, "Alarm channel close failed during shutdown.", isWarning: true);
}
else
{
_logger.Info("NET_DVR_CloseAlarmChan_V30 succeeded for " + s.Device.DeviceId + ".");
}
}
}
catch (Exception ex)
{
_logger.Error("Close alarm channel failed for " + s.Device.DeviceId, ex);
}
try
{
if (s.UserId >= 0)
{
Common.CHCNetSDK.NET_DVR_Logout_V30(s.UserId);
}
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(s.Device.Ip, "NOT CONNECTED", null, null);
}
catch (Exception ex)
{
_logger.Error("Logout failed for " + s.Device.DeviceId, ex);
}
}
_sessions.Clear();
}
private async Task HistoricalSchedulerLoop(CancellationToken token)
{
var intervalMinutes = _config.AttendanceSyncIntervalMinutes > 0
? _config.AttendanceSyncIntervalMinutes
: _config.HistoricalFetchIntervalMinutes;
if (intervalMinutes <= 0)
intervalMinutes = 5;
bool firstRun = true;
while (!token.IsCancellationRequested)
{
bool isImmediateStartupRun = firstRun;
if (!firstRun)
{
try
{
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
firstRun = false;
ExecuteAttendanceFetchCycle(token, isImmediateStartupRun);
}
}
private void ExecuteAttendanceFetchCycle(CancellationToken token, bool isImmediateStartupRun)
{
LogJobCycleStart("attendance", "ATTENDANCE", "--Attendance job started at ");
if (isImmediateStartupRun)
_logger.JobInfo("attendance", "Attendance immediate startup fetch triggered.");
_logger.JobInfo("attendance", "Entering real attendance fetch loop.");
var runtimeDevices = ResolveRuntimeDevices() ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
var sessionsSnapshot = _sessions.ToArray();
_logger.JobInfo("attendance", "Device/session count being processed: runtimeDevices=" + runtimeDevices.Count + ", activeSessions=" + sessionsSnapshot.Length);
if (runtimeDevices.Count == 0)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no runtime devices resolved.");
if (sessionsSnapshot.Length == 0)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no active device sessions.");
if (runtimeDevices.Count == 0 || sessionsSnapshot.Length == 0)
{
LogJobCycleEnd("attendance", "--Attendance job finished at ");
return;
}
var runtimeKeys = new HashSet<string>(
runtimeDevices.Select(d => DeviceIdentity.CanonicalLookupKey(d.DeviceId)).Where(x => !string.IsNullOrWhiteSpace(x)),
StringComparer.Ordinal);
var runtimeIps = new HashSet<string>(
runtimeDevices.Select(d => (d.Ip ?? "").Trim()).Where(x => x.Length > 0),
StringComparer.OrdinalIgnoreCase);
foreach (var d in runtimeDevices)
{
var key = DeviceIdentity.CanonicalLookupKey(d.DeviceId);
var ip = (d.Ip ?? "").Trim();
bool hasSessionById = key.Length > 0 && sessionsSnapshot.Any(s => DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key);
bool hasSessionByIp = ip.Length > 0 && sessionsSnapshot.Any(s => string.Equals((s.Device.Ip ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase));
if (!hasSessionById && !hasSessionByIp)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=DB machine loaded but session missing machine=" + d.DeviceId + " ip=" + (d.Ip ?? ""));
}
var to = DateTime.Now;
var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes);
if (_config.HistoricalFetchLookbackMinutes <= 0)
fallbackFrom = to.AddDays(-1);
int processedSessions = 0;
foreach (var s in sessionsSnapshot)
{
try
{
var sessionKey = DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId);
var sessionIp = (s.Device.Ip ?? "").Trim();
bool eligibleById = sessionKey.Length > 0 && runtimeKeys.Contains(sessionKey);
bool eligibleByIp = sessionIp.Length > 0 && runtimeIps.Contains(sessionIp);
if ((runtimeKeys.Count > 0 || runtimeIps.Count > 0) && !eligibleById && !eligibleByIp)
{
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no matching machine/session found for session device=" + s.Device.DeviceId +
" sessionIp=" + sessionIp);
continue;
}
processedSessions++;
var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason);
var from = lastSync.HasValue ? lastSync.Value.AddSeconds(1) : fallbackFrom;
_logger.JobInfo("attendance", "Attendance fetch window: device=" + s.Device.DeviceId +
" from=" + from.ToString("yyyy-MM-dd HH:mm:ss") +
" to=" + to.ToString("yyyy-MM-dd HH:mm:ss") +
" lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") +
(string.IsNullOrEmpty(lastSyncReadReason) ? "" : " lastSyncReason=" + lastSyncReadReason));
if (from > to)
{
_logger.Info("Scheduled historical ACS fetch skipped (from > to): device=" + s.Device.DeviceId +
", from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ", to=" + to.ToString("yyyy-MM-dd HH:mm:ss") +
(string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason));
continue;
}
DateTime? lastEventTimestamp;
bool dbInsertAllSucceeded;
int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded);
_logger.Info("Scheduled historical ACS fetch completed: device=" + s.Device.DeviceId + ", records=" + n +
", window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + " .. " + to.ToString("yyyy-MM-dd HH:mm:ss") +
", lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") +
(string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason) +
", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)"));
_logger.JobInfo("attendance", "Scheduled fetch device=" + s.Device.DeviceId + " records=" + n +
" window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ".." + to.ToString("yyyy-MM-dd HH:mm:ss"));
_logger.JobInfo("attendance", "MACHINE " + s.Device.DeviceId + " has " + n + " records !");
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value);
if (_config.EnableDatabasePersistence && dbInsertAllSucceeded)
{
if (TryCleanupDeviceAttendanceStorage(s, lastEventTimestamp.Value, out var cleanupErr))
{
_logger.JobInfo("attendance", "Device cleanup OK: machine=" + s.Device.DeviceId +
" ip=" + (s.Device.Ip ?? "") +
" checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
}
else
{
_logger.JobWarn("attendance", "Device cleanup FAILED: machine=" + s.Device.DeviceId +
" ip=" + (s.Device.Ip ?? "") +
" checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
" err=" + cleanupErr);
}
}
}
else if (n == 0 && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(s.Device.DeviceId, to);
_logger.JobInfo("attendance", "No records found; advancing last_sync_date to window end. machine=" + s.Device.DeviceId +
" timestamp=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
}
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
{
_logger.JobWarn("attendance", "LastSync NOT updated for machine=" + s.Device.DeviceId +
" because DB insert failed; same data window will be retried.");
_logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed. machine=" + s.Device.DeviceId);
}
}
catch (Exception ex)
{
_logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex);
_logger.JobError("attendance", "Scheduled fetch FAILED device=" + s.Device.DeviceId + " err=" + ex.Message);
}
}
if (processedSessions == 0)
_logger.JobWarn("attendance", "Attendance fetch method returned early due to guard condition: no eligible sessions to process.");
LogJobCycleEnd("attendance", "--Attendance job finished at ");
}
private async Task TemplateFetchSchedulerLoop(CancellationToken token)
{
bool firstRun = true;
while (!token.IsCancellationRequested)
{
if (!firstRun)
{
try
{
await Task.Delay(TimeSpan.FromHours(_config.TemplateFetchIntervalHours), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
firstRun = false;
LogJobCycleStart("template_fetch", "TEMPLATE", "--Template job started at ");
foreach (var s in _sessions.ToArray())
{
if (token.IsCancellationRequested)
break;
try
{
var outDir = Path.Combine(_config.LogDirectory, "template_fetch", DateTime.Now.ToString("yyyy-MM-dd"), s.Device.DeviceId);
_logger.JobInfo("template_fetch", "Start device=" + s.Device.DeviceId + " outDir=\"" + outDir + "\"");
string jsonPath;
int discoveredUsers;
string err;
bool ok = TryExportAllUsersTemplatesToIsapiFile(
s.Device.DeviceId,
outDir,
30,
5000,
token,
out jsonPath,
out discoveredUsers,
out err);
if (ok)
{
_logger.JobInfo("template_fetch", "DONE device=" + s.Device.DeviceId + " jsonPath=\"" + jsonPath + "\"");
TryUpdateMachineRuntimeState(s.Device.Ip, "IDLE", null, discoveredUsers);
_logger.JobInfo("template_fetch", "DB attendance_machine.total_users updated: machine_ip=" + (s.Device.Ip ?? "") +
" total_users=" + discoveredUsers);
}
else
_logger.JobError("template_fetch", "FAILED device=" + s.Device.DeviceId + " err=" + err);
}
catch (Exception ex)
{
_logger.JobError("template_fetch", "EXCEPTION device=" + s.Device.DeviceId + " err=" + ex.Message);
}
}
LogJobCycleEnd("template_fetch", "--Template job finished at ");
}
}
private void LogJobCycleStart(string jobKey, string jobTitle, string startedLinePrefix)
{
var now = DateTime.Now;
_logger.JobInfo(jobKey, "========================================================");
_logger.JobInfo(jobKey, "SERVICE STARTED");
_logger.JobInfo(jobKey, "Time: " + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture));
_logger.JobInfo(jobKey, "JOB: " + jobTitle);
_logger.JobInfo(jobKey, "=========================");
_logger.JobInfo(jobKey, startedLinePrefix + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture));
}
private void LogJobCycleEnd(string jobKey, string finishedLinePrefix)
{
var now = DateTime.Now;
_logger.JobInfo(jobKey, finishedLinePrefix + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture));
_logger.JobInfo(jobKey, "SERVICE ENDED");
_logger.JobInfo(jobKey, "Time: " + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture));
_logger.JobInfo(jobKey, "========================================================");
}
private bool PersistHistoricalAttendanceEvent(AttendanceEvent ev, out bool dbInserted)
{
dbInserted = false;
if (ev == null)
return false;
if (!_dedupeKeys.TryAdd(ev.DedupeKey, 1))
return false;
if (_dedupeKeys.Count > DedupeMaxEntries)
{
_dedupeKeys.Clear();
_dedupeKeys.TryAdd(ev.DedupeKey, 1);
}
lock (_csvWriteLock)
{
AppendAttendanceCsvLineSafely(ToCsvLine(ev));
}
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
AppendAttendanceToTextFileSafely(ev);
dbInserted = WriteAttendanceToDatabase(ev);
if (dbInserted)
{
// Keep status updated but do not overwrite last_sync_date cursor (that is handled by WriteLastSyncTimestamp).
TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null);
}
return true;
}
private bool TryCleanupDeviceAttendanceStorage(DeviceSession session, DateTime checkTimeLocal, out string error)
{
error = "";
var body = "{ \"EventStorageCfg\": { " +
"\"mode\": \"time\", " +
"\"checkTime\": \"" + checkTimeLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + "\"" +
" } }";
var raw = StdXmlCall(session.UserId, "PUT", "/ISAPI/AccessControl/AcsEvent/StorageCfg?format=json", body, out var sdkErr);
var status = ExtractIsapiStatusSummary(raw);
if (!string.IsNullOrWhiteSpace(sdkErr))
{
error = sdkErr + " ; " + status;
return false;
}
if (status.IndexOf("statusCode=1", StringComparison.OrdinalIgnoreCase) >= 0 ||
status.IndexOf("statusString=OK", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
if (string.IsNullOrWhiteSpace(raw))
return true;
error = "cleanup status not confirmed: " + status;
return false;
}
private string GetLastSyncFilePath(string deviceId)
{
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
if (string.IsNullOrWhiteSpace(key))
key = (deviceId ?? "").Trim();
// Make sure the key is file-system safe.
foreach (var ch in Path.GetInvalidFileNameChars())
key = key.Replace(ch, '_');
return Path.Combine(_config.LogDirectory, "last_sync_acs_" + key + ".txt");
}
private DateTime? ReadLastSyncTimestamp(string deviceId, out string reason)
{
reason = "";
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
{
var machineIp = ResolveMachineIpForDbLookup(deviceId);
if (string.IsNullOrWhiteSpace(machineIp))
{
reason = "db lookup skipped: machine_ip unresolved for deviceId=" + deviceId;
return null;
}
if (_attendanceMachineRepository.TryGetMachineLastSyncDate(machineIp, out var dbLastSync, out var dbErr))
{
if (dbLastSync.HasValue)
{
reason = "db.last_sync_date(machine_ip=" + machineIp + ")";
return dbLastSync.Value;
}
reason = "db.last_sync_date is null (machine_ip=" + machineIp + ")";
return null;
}
reason = "db last_sync_date read failed (machine_ip=" + machineIp + "): " + dbErr;
return null;
}
try
{
var path = GetLastSyncFilePath(deviceId);
if (!File.Exists(path))
{
reason = "last-sync file missing";
return null;
}
var raw = File.ReadAllText(path).Trim();
if (string.IsNullOrWhiteSpace(raw))
{
reason = "last-sync file empty";
return null;
}
// Store as local time in "yyyy-MM-dd HH:mm:ss".
if (DateTime.TryParseExact(raw, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal, out var dt))
return dt;
if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt))
return dt;
reason = "last-sync timestamp parse failed: raw=\"" + raw + "\"";
return null;
}
catch (Exception ex)
{
reason = "last-sync read failed: " + ex.Message;
return null;
}
}
private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal)
{
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
{
var localValue = NormalizeForDbLocalTime(timestampLocal);
var machineIp = ResolveMachineIpForDbLookup(deviceId);
if (string.IsNullOrWhiteSpace(machineIp))
{
_logger.Warn("last_sync_date DB update skipped: machine_ip unresolved for deviceId=" + deviceId);
return;
}
if (!_attendanceMachineRepository.UpdateMachineLastSyncDate(machineIp, localValue, out var dbErr))
_logger.Warn("last_sync_date DB update failed machine_ip=" + machineIp + " err=" + dbErr);
return;
}
var path = GetLastSyncFilePath(deviceId);
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory);
var raw = timestampLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
File.WriteAllText(path, raw);
}
private int FetchAttendanceRecordsCore(
string deviceId,
DateTime fromLocal,
DateTime toLocal,
CancellationToken cancellationToken,
out DateTime? lastEventTimestamp,
out bool dbInsertAllSucceeded)
{
lastEventTimestamp = null;
dbInsertAllSucceeded = true;
var canonicalKey = DeviceIdentity.CanonicalLookupKey(deviceId);
_logger.Info("FetchAttendanceRecordsCore: enter historical fetch; requestedDeviceId=" + deviceId +
" canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey));
var session = FindSession(deviceId);
if (session == null)
{
_logger.Error("FetchAttendanceRecordsCore: session lookup FAILED (device not logged in). requestedDeviceId=" + deviceId +
" canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey) +
"; activeSessionIds=" + string.Join(", ", _sessions.Select(s => s.Device.DeviceId)));
return 0;
}
_logger.Info("FetchAttendanceRecordsCore: session lookup OK userId=" + session.UserId + " sessionDeviceId=" + session.Device.DeviceId);
if (toLocal < fromLocal)
{
_logger.Warn("FetchAttendanceRecordsCore: toLocal < fromLocal; swapping.");
(fromLocal, toLocal) = (toLocal, fromLocal);
}
_logger.Info("Historical ACS fetch START device=" + deviceId + " userId=" + session.UserId +
" (session from login only; alarm channel not required) from=" + fromLocal.ToString("yyyy-MM-dd HH:mm:ss") +
" to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss"));
_logger.JobInfo("attendance", "Fetch START device=" + deviceId + " from=" +
fromLocal.ToString("yyyy-MM-dd HH:mm:ss") + " to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss"));
// Prioritize the officially documented STDXMLConfig + ISAPI AccessControl path for DS-K1T642MFW.
// If anything fails (SDK call error, parse error, unexpected response), we fall back to NET_DVR_GET_ACS_EVENT.
if (IsStdXmlPreferredDevice(session.Device))
{
bool attemptedStdXml;
DateTime? stdLastEventTs;
int stdXmlParsed = TryStdXmlAcsFetchAndEnqueue(
session,
fromLocal,
toLocal,
cancellationToken,
out attemptedStdXml,
out stdLastEventTs,
out dbInsertAllSucceeded);
if (attemptedStdXml)
{
lastEventTimestamp = stdLastEventTs;
return stdXmlParsed;
}
}
DateTime? maxEventTs = null;
var cond = new CHCNetSDK.NET_DVR_ACS_EVENT_COND();
cond.Init();
cond.dwSize = (uint)Marshal.SizeOf(cond);
cond.dwMajor = _config.AcsHistoryMajor;
cond.dwMinor = _config.AcsHistoryMinor;
cond.struStartTime = ToDvrTime(fromLocal);
cond.struEndTime = ToDvrTime(toLocal);
cond.byPicEnable = 0;
cond.szMonitorID = "";
cond.wInductiveEventType = 65535;
IntPtr condPtr = IntPtr.Zero;
int handle = -1;
int total = 0;
int parsedOk = 0;
int parseFail = 0;
try
{
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
Marshal.StructureToPtr(cond, condPtr, false);
handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig(
session.UserId,
(uint)CHCNetSDK.NET_DVR_GET_ACS_EVENT,
condPtr,
(int)cond.dwSize,
null,
IntPtr.Zero);
if (handle < 0)
{
LogSdkFailure("NET_DVR_StartRemoteConfig(NET_DVR_GET_ACS_EVENT)", Common.CHCNetSDK.NET_DVR_GetLastError(),
session.Device.DeviceId, session.Device.Ip, "Historical fetch remote-config session creation failed.", isWarning: false);
return 0;
}
int cfgSize = Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_CFG));
IntPtr cfgPtr = Marshal.AllocHGlobal(cfgSize);
try
{
PrepareAcsEventCfgPointer(cfgPtr, cfgSize);
while (!cancellationToken.IsCancellationRequested)
{
int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, cfgPtr, (uint)cfgSize);
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
{
var cfg = Marshal.PtrToStructure<CHCNetSDK.NET_DVR_ACS_EVENT_CFG>(cfgPtr);
total++;
if (TryBuildAttendanceFromAcsCfg(session, ref cfg, out var ev))
{
if (PersistHistoricalAttendanceEvent(ev, out var dbInserted))
{
parsedOk++;
if (_config.EnableDatabasePersistence && !dbInserted)
dbInsertAllSucceeded = false;
}
if (ev != null)
{
if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value)
maxEventTs = ev.Timestamp;
}
}
else
{
parseFail++;
}
continue;
}
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
{
Thread.Sleep(200);
continue;
}
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
{
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
handle = -1;
break;
}
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FAILED)
{
LogSdkFailure("NET_DVR_GetNextRemoteConfig", Common.CHCNetSDK.NET_DVR_GetLastError(),
session.Device.DeviceId, session.Device.Ip, "Historical fetch remote-config read failed.", isWarning: false);
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
handle = -1;
break;
}
LogSdkFailure("NET_DVR_GetNextRemoteConfig", Common.CHCNetSDK.NET_DVR_GetLastError(),
session.Device.DeviceId, session.Device.Ip,
"Historical fetch returned unexpected SDK status=" + status + ".", isWarning: true);
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
handle = -1;
break;
}
}
finally
{
Marshal.FreeHGlobal(cfgPtr);
}
}
finally
{
if (condPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(condPtr);
}
if (handle >= 0)
{
try { Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
}
}
_logger.Info("Historical ACS fetch DONE device=" + deviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail);
_logger.JobInfo("attendance", "Fetch DONE device=" + deviceId + " rawRows=" + total + " parsedOk=" + parsedOk + " parseSkipped=" + parseFail);
lastEventTimestamp = maxEventTs;
return parsedOk;
}
private static bool IsStdXmlPreferredDevice(HikvisionAttendanceWindowsService.DeviceConfig device)
{
if (device == null)
return false;
// Prefer model/identity strings; DeviceId for this project usually starts with "DS-K1T642MFW-...".
var m = device.Model ?? string.Empty;
if (m.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
var did = device.DeviceId ?? string.Empty;
if (did.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
var serial = device.SerialNumber ?? string.Empty;
if (serial.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
return false;
}
/// <summary>
/// STDXMLConfig diagnostic path using ISAPI/AccessControl endpoints.
/// Returns parsed attendance event count, and sets attemptedStdXml=true only if we successfully parsed at least one event.
/// </summary>
private int TryStdXmlAcsFetchAndEnqueue(
DeviceSession session,
DateTime fromLocal,
DateTime toLocal,
CancellationToken cancellationToken,
out bool attemptedStdXml,
out DateTime? lastEventTimestamp,
out bool dbInsertAllSucceeded)
{
attemptedStdXml = false;
lastEventTimestamp = null;
dbInsertAllSucceeded = true;
try
{
// Step 1-3: capabilities (raw JSON/XML is logged).
// Step 5-6: build request bodies and POST.
// Step 7: log request URL/body + raw response + parsed response status.
uint major = _config.AcsHistoryMajor;
uint minor = _config.AcsHistoryMinor;
//var fromUtc = fromLocal.ToUniversalTime();
//var toUtc = toLocal.ToUniversalTime();
//string startTime = fromUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);
//string endTime = toUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);
string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
var searchId = "1";
int searchResultPosition = 0;
int maxResults = 30; // dynamic (see loop)
string r1 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/capabilities?format=json", null, out var e1);
_logger.Info("STDXMLConfig step1 rawResponse: " + TruncateForLog(r1, 120_000));
if (!string.IsNullOrEmpty(e1))
LogSdkFailureFromText("NET_DVR_STDXMLConfig step1 GET /ISAPI/AccessControl/capabilities", e1, session.Device.DeviceId, session.Device.Ip, isWarning: true);
cancellationToken.ThrowIfCancellationRequested();
string r2 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEvent/capabilities?format=json", null, out var e2);
_logger.Info("STDXMLConfig step2 rawResponse: " + TruncateForLog(r2, 120_000));
if (!string.IsNullOrEmpty(e2))
LogSdkFailureFromText("NET_DVR_STDXMLConfig step2 GET /ISAPI/AccessControl/AcsEvent/capabilities", e2, session.Device.DeviceId, session.Device.Ip, isWarning: true);
cancellationToken.ThrowIfCancellationRequested();
string r3 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEventTotalNum/capabilities?format=json", null, out var e3);
_logger.Info("STDXMLConfig step3 rawResponse: " + TruncateForLog(r3, 120_000));
if (!string.IsNullOrEmpty(e3))
LogSdkFailureFromText("NET_DVR_STDXMLConfig step3 GET /ISAPI/AccessControl/AcsEventTotalNum/capabilities", e3, session.Device.DeviceId, session.Device.Ip, isWarning: true);
// Step 5: build JSON_AcsEventTotalNumCond and POST.
string jsonTotalNumCond = BuildJsonAcsEventTotalNumCond(searchId, major, minor, startTime, endTime);
string totalNumUri = "/ISAPI/AccessControl/AcsEventTotalNum?format=json";
string totalNumRequestUrl = "POST " + totalNumUri;
_logger.Info("STDXMLConfig step5 requestUrl=" + totalNumRequestUrl + " requestBody=" + TruncateForLog(jsonTotalNumCond, 120_000));
string rTotalNum = StdXmlCall(session.UserId, "POST", totalNumUri, jsonTotalNumCond, out var eTotalNum);
_logger.Info("STDXMLConfig step5 rawResponse: " + TruncateForLog(rTotalNum, 120_000));
if (!string.IsNullOrEmpty(eTotalNum))
LogSdkFailureFromText("NET_DVR_STDXMLConfig step5 POST /ISAPI/AccessControl/AcsEventTotalNum", eTotalNum, session.Device.DeviceId, session.Device.Ip, isWarning: true);
_logger.Info("STDXMLConfig step5 parsedResponseStatus: " + ParseStdXmlResponseStatus(rTotalNum));
cancellationToken.ThrowIfCancellationRequested();
// Step 6: build JSON_AcsEventCond and POST (paged), stopping when responseStatusStrg == END.
// Dynamic batch size: start with 30; after we fetch enough rows, increase batch to reduce round-trips.
string acsEventUri = "/ISAPI/AccessControl/AcsEvent?format=json";
int total = 0;
int parsedOk = 0;
int parseFail = 0;
DateTime? maxEventTs = null;
int? totalMatches = TryExtractTotalMatchesFromAcsTotalNum(rTotalNum);
if (totalMatches.HasValue)
_logger.Info("STDXMLConfig step5 totalMatches=" + totalMatches.Value);
while (!cancellationToken.IsCancellationRequested)
{
maxResults = DetermineDynamicAcsBatchSize(parsedOk, maxResults);
string jsonAcsEventCond = BuildJsonAcsEventCond(searchId, searchResultPosition, maxResults, major, minor, startTime, endTime);
string acsEventRequestUrl = "POST " + acsEventUri;
_logger.Info("STDXMLConfig step6 requestUrl=" + acsEventRequestUrl +
" searchResultPosition=" + searchResultPosition +
" maxResults=" + maxResults +
" requestBody=" + TruncateForLog(jsonAcsEventCond, 120_000));
string rAcsEvent = StdXmlCall(session.UserId, "POST", acsEventUri, jsonAcsEventCond, out var eAcsEvent);
_logger.Info("STDXMLConfig step6 rawResponse: " + TruncateForLog(rAcsEvent, 120_000));
if (!string.IsNullOrEmpty(eAcsEvent))
LogSdkFailureFromText("NET_DVR_STDXMLConfig step6 POST /ISAPI/AccessControl/AcsEvent", eAcsEvent, session.Device.DeviceId, session.Device.Ip, isWarning: true);
string statusStr = ParseStdXmlResponseStatus(rAcsEvent);
_logger.Info("STDXMLConfig step6 parsedResponseStatus: " + statusStr);
cancellationToken.ThrowIfCancellationRequested();
var acsEventInfoList = ExtractAcsEventInfoList(rAcsEvent);
_logger.Info("STDXMLConfig extracted events: " + acsEventInfoList.Count);
// Step 10 (goal): map returned fields into our attendance pipeline.
string eventName = MapAcsEventName(major, minor);
if (string.IsNullOrWhiteSpace(eventName))
eventName = "MAJOR_" + major + "_MINOR_" + minor;
string eventType = AcsAttendanceParser.MapMajorCategory(major) + "/" + minor.ToString("X");
bool isSuccessByMinorRule = AcsAttendanceParser.ResolveIsSuccess(major, minor, eventName);
int pageParsedOk = 0;
foreach (var info in acsEventInfoList)
{
total++;
if (!TryBuildAttendanceFromStdAcsInfo(session.Device, info, major, minor, eventName, eventType,
isSuccessByMinorRule, out var ev))
{
parseFail++;
continue;
}
if (PersistHistoricalAttendanceEvent(ev, out var dbInserted))
{
parsedOk++;
pageParsedOk++;
if (_config.EnableDatabasePersistence && !dbInserted)
dbInsertAllSucceeded = false;
}
if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value)
maxEventTs = ev.Timestamp;
}
// Pagination: advance by what the device returned (avoids duplication even if it returns fewer than maxResults).
if (acsEventInfoList.Count > 0)
searchResultPosition += acsEventInfoList.Count;
else
{
// Defensive: if device claims MORE but returns 0, stop to avoid infinite loop.
_logger.Warn("STDXMLConfig paging: got 0 events; stopping to avoid loop. status=" + statusStr +
" pos=" + searchResultPosition + " max=" + maxResults);
break;
}
// Stop conditions
if (statusStr.IndexOf("END", StringComparison.OrdinalIgnoreCase) >= 0)
break;
if (statusStr.IndexOf("MORE", StringComparison.OrdinalIgnoreCase) >= 0)
continue;
// Unknown status: stop; caller will treat this attempt as successful if we parsed anything.
_logger.Warn("STDXMLConfig paging: unexpected responseStatusStrg=\"" + statusStr + "\"; stopping.");
break;
}
_logger.Info("STDXMLConfig fetch DONE device=" + session.Device.DeviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail);
// Treat the STDXML attempt as authoritative if we reached here without throwing,
// even if the date range has no events (avoid redundant SDK remote-config fetch).
attemptedStdXml = true;
if (maxEventTs.HasValue)
// STDXML timestamps are parsed as UTC (we parse the device time with offset, then convert to universal).
// Convert back to local time so incremental cursor stays consistent with scheduler's local `from`/`to`.
lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Utc).ToLocalTime();
else
lastEventTimestamp = null;
return parsedOk;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Error("STDXMLConfig diagnostic fetch failed; falling back to NET_DVR_GET_ACS_EVENT", ex);
attemptedStdXml = false;
lastEventTimestamp = null;
dbInsertAllSucceeded = false;
return 0;
}
}
private static int DetermineDynamicAcsBatchSize(int totalParsedOkSoFar, int currentMaxResults)
{
// Conservative dynamic batching: keep requests small until we know the window is large.
// The AcsEvent ISAPI may return large payloads; this avoids overwhelming the device.
if (totalParsedOkSoFar < 1000)
return 30;
if (totalParsedOkSoFar < 5000)
return 50;
return 100;
}
private static int? TryExtractTotalMatchesFromAcsTotalNum(string responseJson)
{
if (string.IsNullOrWhiteSpace(responseJson))
return null;
try
{
var ser = new JavaScriptSerializer();
object? obj = ser.DeserializeObject(responseJson);
if (obj == null)
return null;
if (TryFindInt(obj, new[] { "totalMatches", "totalMatch", "totalNum", "total", "matchNum" }, out var i))
return i;
}
catch
{
// ignore
}
return null;
}
private static string BuildJsonAcsEventTotalNumCond(string searchId, uint major, uint minor, string startTimeUtc, string endTimeUtc)
{
return "{ \"AcsEventTotalNumCond\": { " +
"\"searchID\": \"" + EscapeJson(searchId) + "\"," +
"\"major\": " + major + "," +
"\"minor\": " + minor + "," +
"\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," +
"\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" +
" } }";
}
private static string BuildJsonAcsEventCond(
string searchId,
int searchResultPosition,
int maxResults,
uint major,
uint minor,
string startTimeUtc,
string endTimeUtc)
{
//string minorPart = minor != 0 ? ",\"minor\": " + minor : "";
string minorPart = ",\"minor\": " + minor;
return "{ \"AcsEventCond\": { " +
"\"searchID\": \"" + EscapeJson(searchId) + "\"," +
"\"searchResultPosition\": " + searchResultPosition + "," +
"\"maxResults\": " + maxResults + "," +
"\"major\": " + major +
minorPart + "," +
"\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," +
"\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" +
" } }";
}
private static string EscapeJson(string value)
{
if (value == null)
return "";
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "\\r")
.Replace("\n", "\\n")
.Replace("\t", "\\t");
}
private string StdXmlCall(int userId, string method, string uri, string? postBody, out string sdkError)
{
sdkError = "";
string raw = "";
IntPtr ptrUrl = IntPtr.Zero;
IntPtr ptrIn = IntPtr.Zero;
IntPtr ptrInput = IntPtr.Zero;
IntPtr ptrOutBuf = IntPtr.Zero;
IntPtr ptrOutput = IntPtr.Zero;
try
{
// Input
var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT();
input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT));
string requestUrl = method + " " + uri;
ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl);
input.lpRequestUrl = ptrUrl;
input.dwRequestUrlLen = (uint)requestUrl.Length;
input.dwRecvTimeOut = 5000; // ms
if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody))
{
ptrIn = Marshal.StringToCoTaskMemAnsi(postBody);
input.lpInBuffer = ptrIn;
input.dwInBufferSize = (uint)postBody.Length;
}
else
{
input.lpInBuffer = IntPtr.Zero;
input.dwInBufferSize = 0;
}
ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)));
Marshal.StructureToPtr(input, ptrInput, false);
// Output
var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT();
output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT));
const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw JSON/XML responses
ptrOutBuf = Marshal.AllocHGlobal(outBufSize);
output.lpOutBuffer = ptrOutBuf;
output.dwOutBufferSize = (uint)outBufSize;
output.lpStatusBuffer = ptrOutBuf;
output.dwStatusSize = (uint)outBufSize;
ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)));
Marshal.StructureToPtr(output, ptrOutput, false);
bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput);
if (!ok)
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
sdkError = "NET_DVR_STDXMLConfig failed err=" + err;
}
var outAfter = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT>(ptrOutput);
int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize);
if (returnedSize > 0 && returnedSize <= outBufSize)
{
byte[] bytes = new byte[returnedSize];
Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize);
raw = Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim();
}
else
{
raw = Marshal.PtrToStringAnsi(ptrOutBuf) ?? "";
}
}
finally
{
if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl);
if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn);
if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput);
if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput);
if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf);
}
return raw;
}
private byte[] StdXmlCallBytes(int userId, string method, string uri, string? postBody, out string sdkError)
{
sdkError = "";
byte[] bytes = Array.Empty<byte>();
IntPtr ptrUrl = IntPtr.Zero;
IntPtr ptrIn = IntPtr.Zero;
IntPtr ptrInput = IntPtr.Zero;
IntPtr ptrOutput = IntPtr.Zero;
IntPtr ptrOutBuf = IntPtr.Zero;
try
{
var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT();
input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT));
string requestUrl = method + " " + uri;
ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl);
input.lpRequestUrl = ptrUrl;
input.dwRequestUrlLen = (uint)requestUrl.Length;
input.dwRecvTimeOut = 5000; // ms
if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody))
{
ptrIn = Marshal.StringToCoTaskMemAnsi(postBody);
input.lpInBuffer = ptrIn;
input.dwInBufferSize = (uint)postBody.Length;
}
else
{
input.lpInBuffer = IntPtr.Zero;
input.dwInBufferSize = 0;
}
ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)));
Marshal.StructureToPtr(input, ptrInput, false);
var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT();
output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT));
const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw responses
ptrOutBuf = Marshal.AllocHGlobal(outBufSize);
output.lpOutBuffer = ptrOutBuf;
output.dwOutBufferSize = (uint)outBufSize;
output.lpStatusBuffer = ptrOutBuf;
output.dwStatusSize = (uint)outBufSize;
ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)));
Marshal.StructureToPtr(output, ptrOutput, false);
bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput);
if (!ok)
{
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
sdkError = "NET_DVR_STDXMLConfig failed err=" + err;
}
var outAfter = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT>(ptrOutput);
int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize);
if (returnedSize > 0 && returnedSize <= outBufSize)
{
bytes = new byte[returnedSize];
Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize);
}
else
{
bytes = Array.Empty<byte>();
}
}
finally
{
if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl);
if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn);
if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput);
if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput);
if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf);
}
return bytes;
}
private static int? TryExtractErrorCode17(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
// Best-effort: Hikvision error responses often include "errorCode": 17 or <errorCode>17</errorCode>
// so we do a small heuristic scan.
try
{
int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return null;
// Search the nearest integer token after the marker.
var tail = text.Substring(idx);
// Simple tokenization: keep digits and '-' only.
var sb = new StringBuilder();
for (int i = 0; i < tail.Length; i++)
{
char c = tail[i];
if (char.IsDigit(c) || c == '-')
sb.Append(c);
else if (sb.Length > 0)
break;
}
if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code))
{
if (code == 17)
return 17;
return code;
}
}
catch
{
// ignore
}
return null;
}
private static bool LooksLikeJson(string text)
{
if (string.IsNullOrWhiteSpace(text))
return false;
var t = text.TrimStart();
return t.StartsWith("{") || t.StartsWith("[");
}
private bool TryParseFingerprintItemsFromIsapiResponse(byte[] responseBytes, out List<FingerprintTemplateExportItem> items, out string parseError)
{
items = new List<FingerprintTemplateExportItem>();
parseError = "";
if (responseBytes == null || responseBytes.Length == 0)
return false;
// Some firmware returns XML/JSON error messages as text; others return binary blob.
string text;
try
{
text = Encoding.UTF8.GetString(responseBytes);
}
catch
{
return false;
}
// If it's not JSON, we treat it as a single opaque binary fingerprint template.
if (!LooksLikeJson(text))
{
items.Add(new FingerprintTemplateExportItem
{
fingerId = 1,
attempted = true,
present = true,
byteLength = responseBytes.Length,
dataBase64 = Convert.ToBase64String(responseBytes),
error = ""
});
return true;
}
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(text);
if (root == null)
return false;
static bool LooksLikeBase64(string? s)
{
if (string.IsNullOrWhiteSpace(s))
return false;
var t = s.Trim();
if (t.Length < 16)
return false;
// allow both standard and URL-safe base64 alphabets.
for (int i = 0; i < t.Length; i++)
{
char c = t[i];
if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
continue;
return false;
}
return true;
}
// Best-effort traversal: find dictionaries with a finger id and a data/base64-like field.
var stack = new Stack<object>();
stack.Push(root);
var candidateFingerIds = new HashSet<int>();
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nested)
stack.Push(nested);
if (kv.Value is object[] arr)
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
// attempt to parse finger id from current dictionary
int? fingerId = null;
if (TryGetIntNullable(d, "fingerPrintID", out var fid1))
fingerId = fid1;
else if (TryGetIntNullable(d, "fingerId", out var fid2))
fingerId = fid2;
else if (TryGetIntNullable(d, "fingerprintId", out var fid3))
fingerId = fid3;
// attempt to find any base64-looking string
if (fingerId.HasValue && fingerId.Value >= 0)
{
string? base64 = null;
// Fast paths for known key names.
if (d.TryGetValue("dataBase64", out var db) && db is string s1 && LooksLikeBase64(s1))
base64 = s1;
else if (d.TryGetValue("fingerData", out var fd) && fd is string s2 && LooksLikeBase64(s2))
base64 = s2;
// Broader heuristic: any base64-looking value in keys that suggest template/bio content.
if (base64 == null)
{
foreach (var kv in d)
{
if (kv.Value is not string ss)
continue;
if (!LooksLikeBase64(ss))
continue;
var key = kv.Key ?? "";
var k = key.ToLowerInvariant();
if (k.Contains("base64") || k.Contains("finger") || k.Contains("template") || k.Contains("data"))
{
base64 = ss;
break;
}
}
}
if (!string.IsNullOrWhiteSpace(base64))
{
candidateFingerIds.Add(fingerId.Value);
items.Add(new FingerprintTemplateExportItem
{
fingerId = (byte)Math.Max(0, Math.Min(10, fingerId.Value)),
attempted = true,
present = true,
dataBase64 = base64,
byteLength = -1,
error = ""
});
}
}
}
else if (cur is object[] arr)
{
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
if (items.Count > 0)
return true;
parseError = "fingerprint JSON parse found no per-finger template objects";
return false;
}
catch (Exception ex)
{
parseError = ex.Message;
return false;
}
}
private bool TryGetIntNullable(Dictionary<string, object> d, string key, out int value)
{
value = 0;
if (!d.TryGetValue(key, out var v) || v == null)
return false;
if (v is int i) { value = i; return true; }
if (v is long l) { value = (int)l; return true; }
if (v is double dd) { value = (int)dd; return true; }
if (v is string s && int.TryParse(s, out var p)) { value = p; return true; }
return false;
}
private static string ExtractIsapiStatusSummary(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return "status=(empty)";
string statusCode = "";
string statusString = "";
string subStatusCode = "";
string errorCode = "";
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(text);
if (root != null)
{
if (TryFindInt(root, new[] { "statusCode" }, out var sCode)) statusCode = sCode.ToString(CultureInfo.InvariantCulture);
if (TryFindString(root, new[] { "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var sStr)) statusString = sStr;
if (TryFindString(root, new[] { "subStatusCode" }, out var sub)) subStatusCode = sub;
if (TryFindInt(root, new[] { "errorCode" }, out var e)) errorCode = e.ToString(CultureInfo.InvariantCulture);
}
}
catch
{
// ignore and fallback to regex below
}
if (string.IsNullOrEmpty(statusCode))
{
var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusCode\"\\s*:\\s*(?<v>-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) statusCode = m.Groups["v"].Value;
}
if (string.IsNullOrEmpty(statusString))
{
var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusString\"\\s*:\\s*\"(?<v>[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) statusString = m.Groups["v"].Value;
}
if (string.IsNullOrEmpty(subStatusCode))
{
var m = System.Text.RegularExpressions.Regex.Match(text, "\"subStatusCode\"\\s*:\\s*\"(?<v>[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) subStatusCode = m.Groups["v"].Value;
}
if (string.IsNullOrEmpty(errorCode))
{
var m = System.Text.RegularExpressions.Regex.Match(text, "\"errorCode\"\\s*:\\s*(?<v>-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) errorCode = m.Groups["v"].Value;
}
return "statusCode=" + (string.IsNullOrEmpty(statusCode) ? "-" : statusCode) +
", statusString=" + (string.IsNullOrEmpty(statusString) ? "-" : statusString) +
", subStatusCode=" + (string.IsNullOrEmpty(subStatusCode) ? "-" : subStatusCode) +
", errorCode=" + (string.IsNullOrEmpty(errorCode) ? "-" : errorCode);
}
private static string ToOneLineSnippet(string? text, int maxLen = 800)
{
if (string.IsNullOrWhiteSpace(text))
return "";
var s = text.Replace("\r", " ").Replace("\n", " ").Trim();
if (s.Length > maxLen)
s = s.Substring(0, maxLen);
return s;
}
private string ProbeCapabilityAndLog(int userId, string uri, string featureName, StreamWriter sw)
{
var raw = StdXmlCall(userId, "GET", uri, null, out var sdkErr);
var status = ExtractIsapiStatusSummary(raw);
var snippet = ToOneLineSnippet(raw);
sw.WriteLine(DateTime.UtcNow.ToString("o") + " feature=" + featureName + " capabilityUri=" + uri +
" sdkErr=" + (string.IsNullOrEmpty(sdkErr) ? "-" : sdkErr) +
" " + status + " raw_snip=\"" + snippet + "\"");
return raw;
}
private bool TrySearchCardInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error)
{
response = "";
error = "";
var body = "{ \"CardInfoSearchCond\": { " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 20, " +
"\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" +
" } }";
response = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/CardInfo/Search?format=json", body, out var sdkErr);
error = sdkErr;
return !string.IsNullOrWhiteSpace(response);
}
private bool TrySearchUserInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error)
{
response = "";
error = "";
// Try targeted search first.
var body1 = "{ \"UserInfoSearchCond\": { " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 5, " +
"\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" +
" } }";
var raw1 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var err1);
if (!string.IsNullOrWhiteSpace(raw1))
{
response = raw1;
error = err1;
return true;
}
// Fallback for firmware that expects UserInfoSearch wrapper and no employee filter.
var body2 = "{ \"UserInfoSearch\": { " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 200 " +
" } }";
var raw2 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var err2);
response = raw2;
error = string.IsNullOrWhiteSpace(err2) ? err1 : err2;
return !string.IsNullOrWhiteSpace(response);
}
private bool TrySearchUserInfoByEmployeeNoIsapiHttp(DeviceSession session, string employeeNo, out string response, out string error)
{
response = "";
error = "";
try
{
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
string url = "http://" + session.Device.Ip + ":" + httpPort + "/ISAPI/AccessControl/UserInfo/Search?format=json";
string body = "{ \"UserInfoSearchCond\": { \"searchID\": \"1\", \"searchResultPosition\": 0, \"maxResults\": 100 } }";
var handler = new HttpClientHandler
{
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
PreAuthenticate = false,
UseDefaultCredentials = false
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
using var res = client.SendAsync(req).GetAwaiter().GetResult();
response = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!res.IsSuccessStatusCode)
{
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
return !string.IsNullOrWhiteSpace(response);
}
_logger.Info("ISAPI HTTP UserInfo/Search ok: device=" + session.Device.DeviceId + ", httpPort=" + httpPort +
", responseLen=" + response.Length + ", source=HttpDigest");
return true;
}
catch (Exception ex)
{
error = ex.Message;
_logger.Warn("ISAPI HTTP UserInfo/Search failed: device=" + session.Device.DeviceId + ", err=" + ex.Message);
return false;
}
}
private bool TrySearchUserInfoByEmployeeNo(DeviceSession session, string employeeNo, out string response, out string error)
{
if (_config.UseIsapiHttpForUserInfo)
{
_logger.Info("UserInfo source: ISAPI HTTP Digest (UseIsapiHttpForUserInfo=true), device=" + session.Device.DeviceId);
if (TrySearchUserInfoByEmployeeNoIsapiHttp(session, employeeNo, out response, out error))
return true;
_logger.Warn("UserInfo HTTP source failed; fallback to SDK STDXML, device=" + session.Device.DeviceId +
", err=" + (string.IsNullOrEmpty(error) ? "-" : error));
}
return TrySearchUserInfoByEmployeeNoIsapi(session.UserId, employeeNo, out response, out error);
}
private bool TryDownloadFaceImageViaIsapiHttp(DeviceSession session, string faceUrl, out byte[] imageBytes, out string error)
{
imageBytes = Array.Empty<byte>();
error = "";
try
{
if (string.IsNullOrWhiteSpace(faceUrl))
{
error = "empty faceUrl";
return false;
}
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
string pathAndQuery = faceUrl;
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var abs))
pathAndQuery = abs.PathAndQuery;
if (!pathAndQuery.StartsWith("/"))
pathAndQuery = "/" + pathAndQuery.TrimStart('/');
// Force configured device IP for HTTP calls.
string url = "http://" + session.Device.Ip + ":" + httpPort + pathAndQuery;
var handler = new HttpClientHandler
{
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
PreAuthenticate = false,
UseDefaultCredentials = false
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var res = client.SendAsync(req).GetAwaiter().GetResult();
imageBytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
if (!res.IsSuccessStatusCode)
{
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
return false;
}
return imageBytes.Length > 0;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static bool TryExtractFaceUrlFromUserInfoSearch(string userInfoJson, string employeeNo, out string faceUrl)
{
faceUrl = "";
if (string.IsNullOrWhiteSpace(userInfoJson))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(userInfoJson);
if (root == null)
return false;
var candidateKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"faceURL", "faceUrl", "pictureURL", "pictureUrl", "photoURL", "photoUrl", "imgUrl"
};
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
// Prefer extracting from matching user object.
bool employeeMatches = false;
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
{
var eno = enoObj.ToString() ?? "";
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
}
if (employeeMatches)
{
foreach (var k in candidateKeys)
{
if (d.TryGetValue(k, out var v) && v is string s && !string.IsNullOrWhiteSpace(s))
{
faceUrl = s.Trim();
return true;
}
}
}
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv.Value is object[] arr)
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
else if (cur is object[] arr2)
{
foreach (var it in arr2)
if (it != null)
stack.Push(it);
}
}
}
catch
{
// best effort only
}
return false;
}
private static bool TryExtractNameFromUserInfoSearch(string userInfoJson, string employeeNo, out string name)
{
name = "";
if (string.IsNullOrWhiteSpace(userInfoJson))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(userInfoJson);
if (root == null)
return false;
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
bool employeeMatches = false;
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
{
var eno = enoObj.ToString() ?? "";
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
}
if (employeeMatches && d.TryGetValue("name", out var nameObj) && nameObj is string s && !string.IsNullOrWhiteSpace(s))
{
name = s.Trim();
return true;
}
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv.Value is object[] arr)
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
else if (cur is object[] arr2)
{
foreach (var it in arr2)
if (it != null)
stack.Push(it);
}
}
}
catch
{
// best effort only
}
return false;
}
private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError)
{
return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError);
}
private string CardInfoSetUpIsapi(int userId, string jsonBody, out string sdkError)
{
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/SetUp?format=json", jsonBody, out sdkError);
}
private string CardInfoRecordIsapi(int userId, string jsonBody, out string sdkError)
{
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Record?format=json", jsonBody, out sdkError);
}
private string CardInfoModifyIsapi(int userId, string jsonBody, out string sdkError)
{
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Modify?format=json", jsonBody, out sdkError);
}
private string CardInfoDeleteIsapi(int userId, string jsonBody, out string sdkError)
{
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Delete?format=json", jsonBody, out sdkError);
}
private bool TryFetchFingerprintTemplatesViaRemoteConfig(
DeviceSession session,
string cardNo,
out List<FingerprintTemplateExportItem> fingerprints,
out string debug)
{
fingerprints = new List<FingerprintTemplateExportItem>();
debug = "";
IntPtr condPtr = IntPtr.Zero;
IntPtr outPtr = IntPtr.Zero;
int handle = -1;
try
{
// Use Common SDK instance (same as NET_DVR_Init) and command aligned with Common struct family.
const uint NET_DVR_GET_FINGERPRINT_CFG = 2150;
var cond = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_INFO_COND();
cond.dwSize = (uint)Marshal.SizeOf(cond);
cond.byCardNo = new byte[32];
cond.byEnableCardReader = new byte[512];
cond.byRes1 = new byte[26];
cond.dwFingerPrintNum = 0xFFFFFFFF; // all fingerprints
cond.byFingerPrintID = 0xFF; // all finger ids
cond.byCallbackMode = 0; // sync pull mode
CopyUtf8(cardNo, cond.byCardNo);
int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length)
cond.byEnableCardReader[readerNo - 1] = 1;
else if (cond.byEnableCardReader.Length > 0)
cond.byEnableCardReader[0] = 1;
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
Marshal.StructureToPtr(cond, condPtr, false);
handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig(
session.UserId,
NET_DVR_GET_FINGERPRINT_CFG,
condPtr,
(int)cond.dwSize,
null,
IntPtr.Zero);
if (handle < 0)
{
int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError());
debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FINGERPRINT_CFG) failed err=" + err;
return false;
}
var outCfg = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG();
outCfg.dwSize = (uint)Marshal.SizeOf(outCfg);
outCfg.byCardNo = new byte[32];
outCfg.byEnableCardReader = new byte[512];
outCfg.byRes1 = new byte[30];
outCfg.byFingerData = new byte[Common.CHCNetSDK.MAX_FINGER_PRINT_LEN];
outCfg.byRes = new byte[64];
int outSize = Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG));
outPtr = Marshal.AllocHGlobal(outSize);
int rows = 0;
int loops = 0;
while (loops++ < 3000)
{
Marshal.StructureToPtr(outCfg, outPtr, false);
int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize);
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
{
rows++;
var row = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG>(outPtr);
int len = (int)Math.Min(row.dwFingerPrintLen, (uint)(row.byFingerData?.Length ?? 0));
if (len > 0)
{
var bytes = new byte[len];
Buffer.BlockCopy(row.byFingerData, 0, bytes, 0, len);
fingerprints.Add(new FingerprintTemplateExportItem
{
fingerId = row.byFingerPrintID,
attempted = true,
present = true,
byteLength = len,
dataBase64 = Convert.ToBase64String(bytes),
error = ""
});
}
continue;
}
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
{
Thread.Sleep(80);
continue;
}
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
{
debug = "fingerprint remote-config finished rows=" + rows;
return true;
}
int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError());
debug = "NET_DVR_GetNextRemoteConfig(fingerprint) status=" + status + ", err=" + err;
return false;
}
debug = "fingerprint remote-config timed out";
return false;
}
finally
{
try { if (handle >= 0) Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
}
}
private bool TryFetchFaceTemplateViaRemoteConfig(
DeviceSession session,
string cardNo,
out FaceTemplateExportItem faceItem,
out string debug)
{
faceItem = new FaceTemplateExportItem
{
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = ""
};
debug = "";
IntPtr condPtr = IntPtr.Zero;
IntPtr outPtr = IntPtr.Zero;
int handle = -1;
try
{
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_COND();
cond.Init();
cond.dwSize = (uint)Marshal.SizeOf(cond);
cond.dwFaceNum = 0xFFFFFFFF; // all faces for this user
cond.byFaceID = 0xFF; // all face IDs
CopyUtf8(cardNo, cond.byCardNo);
int readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length)
cond.byEnableCardReader[readerNo - 1] = 1;
else if (cond.byEnableCardReader.Length > 0)
cond.byEnableCardReader[0] = 1;
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
Marshal.StructureToPtr(cond, condPtr, false);
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
session.UserId,
(uint)EventByDeploy.CHCNetSDK.NET_DVR_GET_FACE,
condPtr,
(int)cond.dwSize,
null,
IntPtr.Zero);
if (handle < 0)
{
int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError());
debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FACE) failed err=" + err;
return false;
}
var outCfg = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG();
outCfg.Init();
outCfg.dwSize = (uint)Marshal.SizeOf(outCfg);
int outSize = Marshal.SizeOf(typeof(EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG));
outPtr = Marshal.AllocHGlobal(outSize);
int rows = 0;
int loops = 0;
while (loops++ < 3000)
{
Marshal.StructureToPtr(outCfg, outPtr, false);
int status = EventByDeploy.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize);
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
{
rows++;
var row = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG>(outPtr);
int len = (int)row.dwFaceLen;
if (len > 0 && row.pFaceBuffer != IntPtr.Zero)
{
var bytes = new byte[len];
Marshal.Copy(row.pFaceBuffer, bytes, 0, len);
faceItem.present = true;
faceItem.byteLength = len;
faceItem.dataBase64 = Convert.ToBase64String(bytes);
faceItem.error = "";
// Keep reading until FINISH so SDK state is clean.
}
continue;
}
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
{
Thread.Sleep(80);
continue;
}
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
{
debug = "face remote-config finished rows=" + rows;
if (!faceItem.present)
faceItem.error = "face not enrolled";
return true;
}
int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError());
debug = "NET_DVR_GetNextRemoteConfig(face) status=" + status + ", err=" + err;
if (err == 17)
faceItem.error = "missing(errorCode17)";
else
faceItem.error = debug;
return false;
}
debug = "face remote-config timed out";
faceItem.error = debug;
return false;
}
finally
{
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
}
}
private sealed class MultipartMixedPart
{
public string headersText = "";
public string contentType = "";
public string? contentDispositionName = null;
public byte[] bodyBytes = Array.Empty<byte>();
}
private static string DecodeBytesForText(byte[] bytes)
{
// Prefer UTF-8, but fall back to ISO-8859-1 to avoid losing ASCII fragments
// (e.g., JSON metadata + multipart boundaries).
try
{
return Encoding.UTF8.GetString(bytes);
}
catch
{
return Encoding.GetEncoding("iso-8859-1").GetString(bytes);
}
}
private static int? TryExtractErrorCodeAny(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
// Common shapes: "errorCode": 17 or <errorCode>17</errorCode>
try
{
int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return null;
var tail = text.Substring(idx);
var sb = new StringBuilder();
for (int i = 0; i < tail.Length; i++)
{
char c = tail[i];
if (char.IsDigit(c) || c == '-')
sb.Append(c);
else if (sb.Length > 0)
break;
}
if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code))
return code;
}
catch
{
// ignore
}
return null;
}
private static bool TryExtractMultipartBoundary(byte[] responseBytes, out string boundary)
{
boundary = "";
if (responseBytes == null || responseBytes.Length == 0)
return false;
// Boundary usually lives in the ASCII preamble. We only need to look at the first chunk.
int scanLen = Math.Min(responseBytes.Length, 16 * 1024);
var head = responseBytes.Take(scanLen).ToArray();
var headText = DecodeBytesForText(head);
// Examples:
// - boundary=someBoundary
// - boundary="someBoundary"
var m = System.Text.RegularExpressions.Regex.Match(
headText,
"boundary\\s*=\\s*\"?(?<b>[^;\\s\\\"]+)\"?",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!m.Success || string.IsNullOrWhiteSpace(m.Groups["b"].Value))
return false;
boundary = m.Groups["b"].Value.Trim();
return !string.IsNullOrWhiteSpace(boundary);
}
private static int IndexOfBytes(byte[] haystack, byte[] needle, int startIndex)
{
if (needle.Length == 0)
return -1;
for (int i = startIndex; i <= haystack.Length - needle.Length; i++)
{
bool ok = true;
for (int j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j])
{
ok = false;
break;
}
}
if (ok)
return i;
}
return -1;
}
private static bool TryParseMultipartMixed(byte[] responseBytes, out List<MultipartMixedPart> parts, out string parseError)
{
parts = new List<MultipartMixedPart>();
parseError = "";
if (responseBytes == null || responseBytes.Length == 0)
return false;
if (!TryExtractMultipartBoundary(responseBytes, out var boundary))
return false;
// Find boundary occurrences.
var boundaryMarker = Encoding.ASCII.GetBytes("--" + boundary);
int pos = 0;
var positions = new List<int>();
while (true)
{
int p = IndexOfBytes(responseBytes, boundaryMarker, pos);
if (p < 0)
break;
positions.Add(p);
pos = p + boundaryMarker.Length;
if (positions.Count > 2000) // guardrail
break;
}
if (positions.Count < 2)
return false;
for (int i = 0; i < positions.Count - 1; i++)
{
int segStart = positions[i] + boundaryMarker.Length;
int segEnd = positions[i + 1];
if (segEnd <= segStart)
continue;
var segment = new byte[segEnd - segStart];
Buffer.BlockCopy(responseBytes, segStart, segment, 0, segment.Length);
// Trim leading CRLF
int trimStart = 0;
while (trimStart < segment.Length && (segment[trimStart] == (byte)'\r' || segment[trimStart] == (byte)'\n'))
trimStart++;
if (trimStart > 0)
segment = segment.Skip(trimStart).ToArray();
// Trim trailing CRLF
int trimEnd = segment.Length;
while (trimEnd > 0 && (segment[trimEnd - 1] == (byte)'\r' || segment[trimEnd - 1] == (byte)'\n'))
trimEnd--;
if (trimEnd != segment.Length)
segment = segment.Take(trimEnd).ToArray();
if (segment.Length == 0)
continue;
// Split headers vs body: look for CRLFCRLF or LFLF.
int headerEnd = IndexOfBytes(segment, new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' }, 0);
int lfHeaderEnd = -1;
if (headerEnd < 0)
lfHeaderEnd = IndexOfBytes(segment, new byte[] { (byte)'\n', (byte)'\n' }, 0);
int splitPos = headerEnd >= 0 ? headerEnd : lfHeaderEnd;
if (splitPos < 0)
{
// No headers: treat as a raw body.
parts.Add(new MultipartMixedPart
{
headersText = "",
contentType = "",
contentDispositionName = null,
bodyBytes = segment
});
continue;
}
var headersBytes = segment.Take(splitPos).ToArray();
var bodyBytes = segment.Skip(splitPos + (headerEnd >= 0 ? 4 : 2)).ToArray();
var headersText = DecodeBytesForText(headersBytes);
// content-type
string contentType = "";
var mType = System.Text.RegularExpressions.Regex.Match(
headersText,
"Content-Type\\s*:\\s*(?<t>[^;\\r\\n]+)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (mType.Success)
contentType = mType.Groups["t"].Value.Trim();
// content-disposition name
string? dispName = null;
var mName = System.Text.RegularExpressions.Regex.Match(
headersText,
"Content-Disposition[\\s\\S]*?name\\s*=\\s*\"?(?<n>[^\";\\r\\n]+)\"?",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (mName.Success)
dispName = mName.Groups["n"].Value.Trim();
parts.Add(new MultipartMixedPart
{
headersText = headersText,
contentType = contentType,
contentDispositionName = dispName,
bodyBytes = bodyBytes
});
}
return parts.Count > 0;
}
private static bool TryExtractJsonTextFromMultipart(byte[] responseBytes, out string jsonText, out string jsonContentType, out string parseError)
{
jsonText = "";
jsonContentType = "";
parseError = "";
jsonText = "";
jsonContentType = "";
parseError = "";
if (!TryParseMultipartMixed(responseBytes, out var parts, out parseError))
return false;
foreach (var p in parts)
{
var ct = p.contentType ?? "";
var bodyText = "";
try { bodyText = DecodeBytesForText(p.bodyBytes); } catch { /* ignore */ }
if (ct.IndexOf("application/json", StringComparison.OrdinalIgnoreCase) >= 0 && LooksLikeJson(bodyText))
{
jsonText = bodyText.Trim();
jsonContentType = ct;
return true;
}
if (LooksLikeJson(bodyText) && bodyText.IndexOf("\"errorCode\"", StringComparison.OrdinalIgnoreCase) >= 0)
{
jsonText = bodyText.Trim();
jsonContentType = ct;
return true;
}
}
// Fallback: first JSON-like part.
foreach (var p in parts)
{
var bodyText = DecodeBytesForText(p.bodyBytes);
if (LooksLikeJson(bodyText))
{
jsonText = bodyText.Trim();
jsonContentType = p.contentType ?? "";
return true;
}
}
return false;
}
private static bool LooksLikeBase64(string? s)
{
if (string.IsNullOrWhiteSpace(s))
return false;
var t = s.Trim();
if (t.Length < 16)
return false;
for (int i = 0; i < t.Length; i++)
{
char c = t[i];
if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
continue;
return false;
}
return true;
}
private static bool TryExtractBase64FromJsonText(string jsonText, IEnumerable<string> preferredKeySubstrings, out string base64, out string debug)
{
base64 = "";
debug = "";
if (string.IsNullOrWhiteSpace(jsonText))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(jsonText);
if (root == null)
return false;
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv.Value is object[] arr)
foreach (var it in arr) if (it != null) stack.Push(it);
if (kv.Value is string s && LooksLikeBase64(s))
{
var key = kv.Key ?? "";
var ok = preferredKeySubstrings.Any(p => key.IndexOf(p, StringComparison.OrdinalIgnoreCase) >= 0);
if (ok)
{
base64 = s;
debug = "matchedKey=\"" + key + "\"";
return true;
}
}
}
}
else if (cur is object[] arr2)
{
foreach (var it in arr2)
if (it != null)
stack.Push(it);
}
}
}
catch (Exception ex)
{
debug = ex.Message;
}
return false;
}
private bool TryFetchFingerprintTemplatesViaIsapiDoc(
DeviceSession session,
string cardNo,
out List<FingerprintTemplateExportItem> fingerprints,
out string debug)
{
fingerprints = new List<FingerprintTemplateExportItem>();
debug = "";
int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
bool seenError17 = false;
bool seenNotSupport = false;
// Pro Series search endpoint for fingerprint export/readback.
var searchUrl = "/ISAPI/AccessControl/FingerPrintUpload?format=json";
var employeeNo = cardNo.Trim();
// Body shapes differ by firmware; try documented/compatibility variants.
// Capability dump for this device shows these fields are expected:
// employeeNo, enableCardReader, fingerPrintID, fingerType.
var bodies = new List<string>
{
"{ \"FingerPrintSearchCond\": { " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
"\"enableCardReader\": [" + readerNo + "]," +
"\"fingerPrintID\": 1," +
"\"fingerType\": \"normalFP\"" +
" } }",
"{ \"FingerPrintSearchCond\": { " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
"\"cardReaderNo\": " + readerNo + "," +
"\"fingerPrintID\": 1," +
"\"fingerType\": \"normalFP\"" +
" } }",
"{ \"FingerPrintCond\": { " +
"\"searchID\": \"1\", \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
"\"enableCardReader\": [" + readerNo + "]," +
"\"fingerPrintID\": 1," +
"\"fingerType\": \"normalFP\" " +
" } }",
"{ " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
"\"enableCardReader\": [" + readerNo + "]," +
"\"fingerPrintID\": 1," +
"\"fingerType\": \"normalFP\" " +
" }"
};
foreach (var body in bodies)
{
var fpBytes = StdXmlCallBytes(session.UserId, "POST", searchUrl, body, out var sdkErr);
if (fpBytes.Length == 0)
continue;
var decoded = DecodeBytesForText(fpBytes);
var errCode = TryExtractErrorCodeAny(decoded);
var statusSummary = ExtractIsapiStatusSummary(decoded);
debug = "FingerPrintUpload search tried; sdkErr=\"" + sdkErr + "\", " +
"status=(" + statusSummary + "), errCode=" + (errCode.HasValue ? errCode.Value.ToString() : "(null)");
if (statusSummary.IndexOf("subStatusCode=notSupport", StringComparison.OrdinalIgnoreCase) >= 0)
seenNotSupport = true;
if (errCode.HasValue && errCode.Value == 17)
{
seenError17 = true;
fingerprints = new List<FingerprintTemplateExportItem>
{
new FingerprintTemplateExportItem
{
fingerId = 1,
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = "ISAPI fingerprint errorCode=17"
}
};
return true;
}
// Try direct JSON parse first.
if (TryParseFingerprintItemsFromIsapiResponse(fpBytes, out fingerprints, out var parseErr) && fingerprints.Count > 0)
return true;
// Try multipart: locate JSON part and parse it.
if (TryExtractJsonTextFromMultipart(fpBytes, out var jsonText, out var jsonCt, out var parseMultipartErr))
{
var jsonBytes = Encoding.UTF8.GetBytes(jsonText);
if (TryParseFingerprintItemsFromIsapiResponse(jsonBytes, out fingerprints, out var parseErr2) && fingerprints.Count > 0)
return true;
}
}
if (seenError17)
return true;
if (seenNotSupport)
debug = string.IsNullOrWhiteSpace(debug) ? "FingerPrintUpload not supported on this firmware/path" : (debug + "; notSupport");
return false;
}
private sealed class FaceLibCandidate
{
public int fdId;
public string faceLibType = "";
}
private static bool TryParseFaceLibCandidatesFromFdLibResponse(byte[] responseBytes, out List<FaceLibCandidate> candidates, out string parseError)
{
candidates = new List<FaceLibCandidate>();
parseError = "";
if (responseBytes == null || responseBytes.Length == 0)
return false;
var text = "";
try { text = DecodeBytesForText(responseBytes); } catch { /* ignore */ }
if (!LooksLikeJson(text))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(text);
if (root == null)
return false;
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
// Look for dictionaries that have both an FDID and a faceLibType.
int? fdid = null;
if (TryGetIntNullableStatic(d, "FDID", out var v1)) fdid = v1;
if (!fdid.HasValue && TryGetIntNullableStatic(d, "fdId", out var v2)) fdid = v2;
string? faceLibType = null;
if (TryGetStringStatic(d, "faceLibType", out var t1)) faceLibType = t1;
if (faceLibType == null && TryGetStringStatic(d, "faceLib", out var t2)) faceLibType = t2;
if (faceLibType == null && TryGetStringStatic(d, "libType", out var t3)) faceLibType = t3;
if (fdid.HasValue && !string.IsNullOrWhiteSpace(faceLibType))
{
candidates.Add(new FaceLibCandidate
{
fdId = fdid.Value,
faceLibType = faceLibType.Trim()
});
}
foreach (var kv in d.Values)
{
if (kv is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv is object[] arr)
foreach (var it in arr) if (it != null) stack.Push(it);
}
}
else if (cur is object[] arr2)
{
foreach (var it in arr2) if (it != null) stack.Push(it);
}
}
}
catch (Exception ex)
{
parseError = ex.Message;
return false;
}
return candidates.Count > 0;
}
private static bool TryGetIntNullableStatic(Dictionary<string, object> d, string key, out int value)
{
value = 0;
if (!d.TryGetValue(key, out var v) || v == null)
return false;
if (v is int i) { value = i; return true; }
if (v is long l) { value = (int)l; return true; }
if (v is double dd) { value = (int)dd; return true; }
if (v is string s && int.TryParse(s, out var p)) { value = p; return true; }
return false;
}
private static bool TryGetStringStatic(Dictionary<string, object> d, string key, out string value)
{
value = "";
if (!d.TryGetValue(key, out var v) || v == null)
return false;
if (v is string s)
{
value = s;
return true;
}
value = v.ToString() ?? "";
return !string.IsNullOrWhiteSpace(value);
}
private bool TryFetchFaceTemplateViaIsapiDoc(
DeviceSession session,
string cardNo,
out FaceTemplateExportItem faceItem,
out string debug)
{
faceItem = new FaceTemplateExportItem
{
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = ""
};
debug = "";
bool seenError17 = false;
static bool TryFindFaceRecordPointers(string json, out string fpid, out string faceUrl)
{
fpid = "";
faceUrl = "";
if (string.IsNullOrWhiteSpace(json))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(json);
if (root == null)
return false;
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv.Value is object[] arr)
foreach (var it in arr) if (it != null) stack.Push(it);
}
if (string.IsNullOrWhiteSpace(fpid))
{
if (d.TryGetValue("FPID", out var fp) && fp != null) fpid = fp.ToString() ?? "";
else if (d.TryGetValue("fPID", out var fp2) && fp2 != null) fpid = fp2.ToString() ?? "";
}
if (string.IsNullOrWhiteSpace(faceUrl))
{
if (d.TryGetValue("faceURL", out var fu) && fu is string s1 && !string.IsNullOrWhiteSpace(s1)) faceUrl = s1;
else if (d.TryGetValue("pictureURL", out var fu2) && fu2 is string s2 && !string.IsNullOrWhiteSpace(s2)) faceUrl = s2;
}
if (!string.IsNullOrWhiteSpace(faceUrl))
return true;
}
else if (cur is object[] arr2)
{
foreach (var it in arr2)
if (it != null)
stack.Push(it);
}
}
}
catch
{
return false;
}
return !string.IsNullOrWhiteSpace(faceUrl);
}
static string NormalizeFaceUrlToIsapiPath(string url)
{
if (string.IsNullOrWhiteSpace(url))
return "";
if (url.StartsWith("/"))
return url;
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(url, UriKind.Absolute, out var u))
return u.PathAndQuery;
}
return url;
}
// Discover face picture libraries.
var fdLibBytes = StdXmlCallBytes(session.UserId, "GET", "/ISAPI/Intelligent/FDLib?format=json", null, out var fdLibSdkErr);
if (fdLibBytes.Length == 0)
{
debug = "FDLib discovery returned empty: sdkErr=\"" + fdLibSdkErr + "\"";
faceItem.error = debug;
return false;
}
if (!TryParseFaceLibCandidatesFromFdLibResponse(fdLibBytes, out var libs, out var fdLibParseErr) || libs.Count == 0)
{
debug = "FDLib parse yielded no candidates: parseErr=\"" + fdLibParseErr + "\"";
faceItem.error = debug;
return false;
}
var fdSearchUrl = "/ISAPI/Intelligent/FDLib/FDSearch?format=json";
foreach (var lib in libs)
{
// Pro Series schema (12.3.2.6): root-level search fields.
// Try multiple compatible variants to avoid badJsonContent/MessageParametersLack.
var bodies = new List<string>
{
"{ " +
"\"searchID\": \"1\", " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
"\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"," +
"\"gender\": \"any\", " +
"\"certificateType\": \"ID\" " +
" }",
"{ " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
"\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" +
" }",
"{ " +
"\"searchResultPosition\": 0, " +
"\"maxResults\": 10, " +
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
"\"employeeNo\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" +
" }"
};
for (int bi = 0; bi < bodies.Count; bi++)
{
var body = bodies[bi];
var fdBytes = StdXmlCallBytes(session.UserId, "POST", fdSearchUrl, body, out var fdSdkErr);
if (fdBytes.Length == 0)
continue;
var decoded = DecodeBytesForText(fdBytes);
var errCode = TryExtractErrorCodeAny(decoded);
debug = "FDSearch variant#" + (bi + 1) + " libType=\"" + lib.faceLibType + "\" FDID=" + lib.fdId +
" sdkErr=\"" + fdSdkErr + "\" " + ExtractIsapiStatusSummary(decoded);
if (errCode.HasValue && errCode.Value == 17)
{
seenError17 = true;
continue;
}
if (!TryFindFaceRecordPointers(decoded, out var fpid, out var faceUrl) || string.IsNullOrWhiteSpace(faceUrl))
continue;
var facePath = NormalizeFaceUrlToIsapiPath(faceUrl);
if (string.IsNullOrWhiteSpace(facePath))
continue;
var picBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out var picSdkErr);
if (picBytes.Length > 0)
{
faceItem.present = true;
faceItem.byteLength = picBytes.Length;
faceItem.dataBase64 = Convert.ToBase64String(picBytes);
faceItem.error = "ok(faceURL, FPID=" + (string.IsNullOrWhiteSpace(fpid) ? "-" : fpid) + ")";
return true;
}
debug = debug + ", faceURLGetErr=\"" + picSdkErr + "\", faceURL=\"" + facePath + "\"";
}
}
faceItem.present = false;
faceItem.byteLength = 0;
faceItem.dataBase64 = "";
faceItem.error = seenError17 ? "missing(errorCode17)" : "face not found (FDSearch by employeeNo)";
return false;
}
public bool TryExportAllUsersTemplatesToIsapiFile(
string deviceId,
string? outDir,
int pageSize,
int maxUsers,
CancellationToken cancellationToken,
out string writtenJsonPath,
out int discoveredUsers,
out string error)
{
writtenJsonPath = "";
discoveredUsers = 0;
error = "";
try
{
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
if (pageSize <= 0) pageSize = 30;
if (maxUsers <= 0) maxUsers = 10000;
var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim();
Directory.CreateDirectory(dir);
writtenJsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId).Replace(".json", "_isapi.json"));
var faceLogPath = Path.Combine(dir, "face_templates_log.txt");
var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt");
var templateInternalRoot = Path.Combine(_config.LogDirectory, "internal_logs", "template_internal_logs");
var templateFaceInternalDir = Path.Combine(templateInternalRoot, "face");
var templateFingerInternalDir = Path.Combine(templateInternalRoot, "finger");
Directory.CreateDirectory(templateFaceInternalDir);
Directory.CreateDirectory(templateFingerInternalDir);
var localDate = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var templateFaceStatusLogPath = Path.Combine(templateFaceInternalDir, "face_templates_fetch_" + localDate + ".txt");
var templateFingerStatusLogPath = Path.Combine(templateFingerInternalDir, "finger_templates_fetch_" + localDate + ".txt");
var perUserDir = Path.Combine(dir, "per_user");
Directory.CreateDirectory(perUserDir);
var root = new AllUsersTemplateExportPayload
{
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
deviceId = deviceId.Trim(),
userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search"
};
var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken);
root.listFetchError = listErr;
discoveredUsers = cardNos.Count;
// DB population: attendance_machine_user (device → DB users list)
int upsertedUsers = 0;
int upsertFailed = 0;
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
foreach (var eno in cardNos)
{
var serial = (eno ?? "").Trim();
if (serial.Length == 0)
continue;
if (_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), serial, "", out var upErr))
upsertedUsers++;
else
upsertFailed++;
}
_logger.JobInfo("template_fetch", "DB attendance_machine_user upsert: machine_id=" + deviceId.Trim() +
" discoveredUsers=" + discoveredUsers + " upserted=" + upsertedUsers + " failed=" + upsertFailed);
}
using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
using var templateFaceStatusSw = new StreamWriter(templateFaceStatusLogPath, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
using var templateFingerStatusSw = new StreamWriter(templateFingerStatusLogPath, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export face templates");
fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export fingerprint templates");
if (new FileInfo(templateFaceStatusLogPath).Length == 0)
{
templateFaceStatusSw.WriteLine("========================================");
templateFaceStatusSw.WriteLine("FACE TEMPLATE FETCH DETAILS");
templateFaceStatusSw.WriteLine("Device IP: " + (session.Device.Ip ?? ""));
templateFaceStatusSw.WriteLine("Date: " + localDate);
templateFaceStatusSw.WriteLine("========================================");
}
if (new FileInfo(templateFingerStatusLogPath).Length == 0)
{
templateFingerStatusSw.WriteLine("========================================");
templateFingerStatusSw.WriteLine("FINGERPRINT TEMPLATE FETCH DETAILS");
templateFingerStatusSw.WriteLine("Device IP: " + (session.Device.Ip ?? ""));
templateFingerStatusSw.WriteLine("Date: " + localDate);
templateFingerStatusSw.WriteLine("========================================");
}
_logger.Info("ExportAllTemplates ISAPI: device=" + deviceId + ", discoveredUsers=" + cardNos.Count +
", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr));
var faceFetched = new List<string>();
var faceNotFetched = new List<string>();
var fingerFetched = new List<string>();
var fingerNotFetched = new List<string>();
foreach (var cardNo in cardNos)
{
if (cancellationToken.IsCancellationRequested)
break;
if (string.IsNullOrWhiteSpace(cardNo))
continue;
var userPayload = new UserTemplateExportPayload
{
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
deviceId = deviceId.Trim(),
cardNo = cardNo.Trim(),
sdkImplementationNote =
"Pro Series ISAPI flow via NET_DVR_STDXMLConfig: capability-first, user/card search, fingerprint family (FingerPrintCfg/FingerPrintDownload/FingerPrintProgress), and face FDLib/FDSearch. Old /Face/{id}/picture and /FingerPrint/{id}/data are not used as primary."
};
// Card flow probe for this employeeNo/person key.
if (TrySearchCardInfoByEmployeeNoIsapi(session.UserId, cardNo.Trim(), out var cardSearchRaw, out var cardSearchErr))
{
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
" cardInfoSearch status=(" + ExtractIsapiStatusSummary(cardSearchRaw) + ")" +
" sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr) +
" raw_snip=\"" + ToOneLineSnippet(cardSearchRaw) + "\"");
}
else
{
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
" cardInfoSearch failed sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr));
}
// Face flow: capability-first, then FDLib/FDSearch.
var fdCapRaw = ProbeCapabilityAndLog(session.UserId, "/ISAPI/Intelligent/FDLib/capabilities?format=json", "face.FDLib", faceSw);
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw);
bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 ||
fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0;
userPayload.faceReadbackSupported = !fdSearchDisabledByCap;
var faceItemDoc = new FaceTemplateExportItem();
var faceDocDebug = "";
if (fdSearchDisabledByCap)
{
// Capability explicitly reports FDSearch unsupported on this firmware.
userPayload.face = new FaceTemplateExportItem
{
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = "FDSearch unsupported by capability (isSuportFDSearch=false); enrolled face readback not available via FDSearch on this device"
};
}
else
{
TryFetchFaceTemplateViaIsapiDoc(session, cardNo, out faceItemDoc, out faceDocDebug);
userPayload.face = faceItemDoc;
}
if (!userPayload.face.present)
{
userPayload.face = new FaceTemplateExportItem
{
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = string.IsNullOrWhiteSpace(faceDocDebug)
? "Face export/readback not confirmed by Pro Series flow on this device. Capture/add may be supported; enrolled readback returned no data."
: faceDocDebug
};
if (fdSearchDisabledByCap)
userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false";
}
// Fallback: if person export/search returns a face/picture URL, download it.
if (!userPayload.face.present &&
TrySearchUserInfoByEmployeeNo(session, cardNo.Trim(), out var userInfoRaw, out var userInfoErr) &&
TryExtractFaceUrlFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userFaceUrl))
{
// Best-effort: enrich DB user name if present in UserInfo/Search payload.
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null &&
TryExtractNameFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userName))
{
_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), cardNo.Trim(), userName ?? "", out _);
}
var facePath = userFaceUrl;
if (facePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
facePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(facePath, UriKind.Absolute, out var u))
facePath = u.PathAndQuery;
}
byte[] userFaceBytes;
string userFaceErr;
bool faceDownloaded = _config.UseIsapiHttpForUserInfo
? TryDownloadFaceImageViaIsapiHttp(session, facePath, out userFaceBytes, out userFaceErr)
: (userFaceBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out userFaceErr)).Length > 0;
if (faceDownloaded && userFaceBytes.Length > 0)
{
userPayload.face = new FaceTemplateExportItem
{
attempted = true,
present = true,
byteLength = userFaceBytes.Length,
dataBase64 = Convert.ToBase64String(userFaceBytes),
error = ""
};
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
" faceSource=userInfoUrlFallback faceUrl=\"" + facePath + "\" len=" + userFaceBytes.Length);
}
else
{
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
" faceSource=userInfoUrlFallbackFailed faceUrl=\"" + facePath + "\" err=" +
(string.IsNullOrEmpty(userFaceErr) ? "-" : userFaceErr));
}
}
if (!userPayload.face.present)
{
// Make missing template errors explicit in the face log.
var err = userPayload.face.error ?? "";
if (err.IndexOf("17", StringComparison.OrdinalIgnoreCase) >= 0)
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " missing(errorCode17)=" + err);
else
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " faceError=" + err);
}
templateFaceStatusSw.WriteLine(DateTime.UtcNow.ToString("o") +
" machine_name=" + deviceId +
" machine_ip=" + (session.Device.Ip ?? "") +
" emp_no=" + cardNo.Trim() +
" template=" + (userPayload.face.present ? "fetched" : "not_fetched"));
if (_config.EnableDbIntegration &&
_config.EnableTemplateDbPersistence &&
userPayload.face.present &&
!string.IsNullOrWhiteSpace(userPayload.face.dataBase64) &&
_attendanceMachineFaceTemplateRepository != null)
{
try
{
var bytes = Convert.FromBase64String(userPayload.face.dataBase64);
if (!_attendanceMachineFaceTemplateRepository.UpsertFaceTemplate(cardNo.Trim(), bytes, DateTime.UtcNow, true, out var dbErr))
_logger.Warn("Template DB upsert failed emp_no=" + cardNo.Trim() + " err=" + dbErr);
}
catch (Exception ex)
{
_logger.Warn("Template DB upsert decode failed emp_no=" + cardNo.Trim() + " err=" + ex.Message);
}
}
if (userPayload.face.present) faceFetched.Add(cardNo.Trim()); else faceNotFetched.Add(cardNo.Trim());
// Fingerprint flow: capability-first, then FingerPrintDownload family.
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/FingerPrintCfg/capabilities?format=json", "fingerprint.FingerPrintCfg", fpSw);
var fpItemsDoc = new List<FingerprintTemplateExportItem>();
var fpDocDebug = "";
bool fpDocOk = TryFetchFingerprintTemplatesViaRemoteConfig(session, cardNo, out fpItemsDoc, out fpDocDebug);
if (!fpDocOk || fpItemsDoc.Count == 0)
{
// Fallback to Pro-Series ISAPI fingerprint search/upload endpoint family.
fpDocOk = TryFetchFingerprintTemplatesViaIsapiDoc(session, cardNo, out fpItemsDoc, out fpDocDebug);
}
if (fpDocOk && fpItemsDoc.Count > 0)
{
userPayload.fingerprints = fpItemsDoc;
}
else
{
userPayload.fingerprints = new List<FingerprintTemplateExportItem>
{
new FingerprintTemplateExportItem
{
fingerId = 1,
attempted = true,
present = false,
byteLength = 0,
dataBase64 = "",
error = string.IsNullOrWhiteSpace(fpDocDebug)
? "Fingerprint export/readback not confirmed by Pro Series ISAPI flow on this device. Management APIs may be supported."
: fpDocDebug
}
};
}
foreach (var fp in userPayload.fingerprints)
{
fpSw.WriteLine(DateTime.UtcNow.ToString("o") +
" device=" + deviceId +
" cardNo=" + cardNo +
" fingerId=" + fp.fingerId +
" present=" + fp.present +
" len=" + fp.byteLength +
" error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error));
}
bool anyFingerprintFetched = userPayload.fingerprints.Any(x => x.present);
templateFingerStatusSw.WriteLine(DateTime.UtcNow.ToString("o") +
" machine_name=" + deviceId +
" machine_ip=" + (session.Device.Ip ?? "") +
" emp_no=" + cardNo.Trim() +
" template=" + (anyFingerprintFetched ? "fetched" : "not_fetched"));
if (anyFingerprintFetched) fingerFetched.Add(cardNo.Trim()); else fingerNotFetched.Add(cardNo.Trim());
root.users.Add(userPayload);
// Per-user JSON file as requested.
var perUserPath = Path.Combine(perUserDir, "user_" + cardNo.Trim() + "_templates.json");
var perJson = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(userPayload);
File.WriteAllText(perUserPath, perJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root);
File.WriteAllText(writtenJsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
_logger.Info("ExportAllTemplates ISAPI: wrote combined json path=" + writtenJsonPath + ", users=" + root.users.Count);
if (faceFetched.Count == 0)
templateFaceStatusSw.WriteLine("No face templates to fetch for this device.");
if (fingerFetched.Count == 0)
templateFingerStatusSw.WriteLine("No fingerprint templates to fetch for this device.");
_logger.JobInfo("template_fetch", "========================================");
_logger.JobInfo("template_fetch", "TEMPLATE FETCH SUMMARY");
_logger.JobInfo("template_fetch", "========================================");
_logger.JobInfo("template_fetch", "Device IP: " + (session.Device.Ip ?? ""));
_logger.JobInfo("template_fetch", "Device ID: " + deviceId);
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "=== FACE TEMPLATES ===");
_logger.JobInfo("template_fetch", "Total users processed: " + root.users.Count);
_logger.JobInfo("template_fetch", "Users with face template fetched: " + faceFetched.Count);
_logger.JobInfo("template_fetch", "Users with NO face template: " + faceNotFetched.Count);
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "User list with face templates:");
if (faceFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
foreach (var emp in faceFetched) _logger.JobInfo("template_fetch", " - " + emp + " (fetched)");
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "User list with NO face templates:");
if (faceNotFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
foreach (var emp in faceNotFetched) _logger.JobInfo("template_fetch", " - " + emp);
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "=== FINGERPRINT TEMPLATES ===");
_logger.JobInfo("template_fetch", "Total users processed: " + root.users.Count);
_logger.JobInfo("template_fetch", "Users with fingerprint template fetched: " + fingerFetched.Count);
_logger.JobInfo("template_fetch", "Users with NO fingerprint template: " + fingerNotFetched.Count);
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "User list with fingerprint templates:");
if (fingerFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
foreach (var emp in fingerFetched) _logger.JobInfo("template_fetch", " - " + emp + " (fetched)");
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "User list with NO fingerprint templates:");
if (fingerNotFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
foreach (var emp in fingerNotFetched) _logger.JobInfo("template_fetch", " - " + emp);
_logger.JobInfo("template_fetch", "");
_logger.JobInfo("template_fetch", "========================================");
return true;
}
catch (OperationCanceledException)
{
error = "cancelled";
return false;
}
catch (Exception ex)
{
error = ex.Message;
_logger.Error("TryExportAllUsersTemplatesToIsapiFile failed", ex);
return false;
}
}
private static string ParseStdXmlResponseStatus(string responseJson)
{
if (string.IsNullOrWhiteSpace(responseJson))
return "(empty)";
try
{
var ser = new JavaScriptSerializer();
object? obj = ser.DeserializeObject(responseJson);
if (obj == null)
return "(unparsed)";
if (TryFindString(obj, new[] { "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var s))
return s;
if (TryFindString(obj, new[] { "ResponseStatus" }, out var s2))
return s2;
if (TryFindInt(obj, new[] { "statusCode", "responseStatusCode" }, out var i))
return "code=" + i;
return "(parsed:no known status keys)";
}
catch
{
var m = System.Text.RegularExpressions.Regex.Match(
responseJson,
"\"responseStatus(Strg|Str|String)\"\\s*:\\s*\"(?<v>[^\"]*)\"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success)
return m.Groups["v"].Value;
return "(unparsed)";
}
}
private static bool TryFindString(object obj, IEnumerable<string> keys, out string value)
{
value = "";
var stack = new Stack<object>();
stack.Push(obj);
var keySet = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (keySet.Contains(kv.Key) && kv.Value is string sv)
{
value = sv;
return true;
}
if (kv.Value != null)
stack.Push(kv.Value);
}
}
else if (cur is object[] arr)
{
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
return false;
}
private static bool TryFindInt(object obj, IEnumerable<string> keys, out int value)
{
value = 0;
var stack = new Stack<object>();
stack.Push(obj);
var keySet = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
foreach (var kv in d)
{
if (keySet.Contains(kv.Key))
{
if (kv.Value is int i)
{
value = i;
return true;
}
if (kv.Value is long l)
{
value = (int)l;
return true;
}
if (kv.Value is double dd)
{
value = (int)dd;
return true;
}
if (kv.Value is string s && int.TryParse(s, out var p))
{
value = p;
return true;
}
}
if (kv.Value != null)
stack.Push(kv.Value);
}
}
else if (cur is object[] arr)
{
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
return false;
}
private static List<Dictionary<string, object>> ExtractAcsEventInfoList(string responseJson)
{
var result = new List<Dictionary<string, object>>();
if (string.IsNullOrWhiteSpace(responseJson))
return result;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(responseJson);
if (root == null)
return result;
// Typical structure: { "AcsEvent": { "InfoList": [ ... ] } }
if (root is Dictionary<string, object> d && d.TryGetValue("AcsEvent", out var acsObj) && acsObj is Dictionary<string, object> acsDict)
{
if (acsDict.TryGetValue("InfoList", out var infoListObj) && infoListObj is object[] arr)
{
foreach (var it in arr)
{
if (it is Dictionary<string, object> itemDict)
result.Add(itemDict);
}
if (result.Count > 0)
return result;
}
}
// Recursive best-effort fallback.
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0 && result.Count == 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> cd)
{
foreach (var kv in cd)
{
if (string.Equals(kv.Key, "InfoList", StringComparison.OrdinalIgnoreCase) && kv.Value is object[] arr)
{
foreach (var it in arr)
{
if (it is Dictionary<string, object> itemDict)
result.Add(itemDict);
}
break;
}
if (kv.Value != null)
stack.Push(kv.Value);
}
}
else if (cur is object[] arr)
{
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
}
catch
{
// Best-effort only; caller logs raw response.
}
return result;
}
private static bool TryBuildAttendanceFromStdAcsInfo(
HikvisionAttendanceWindowsService.DeviceConfig device,
Dictionary<string, object> info,
uint rawMajor,
uint rawMinor,
string eventName,
string eventType,
bool isSuccessByMinorRule,
out AttendanceEvent attendanceEvent)
{
attendanceEvent = null!;
// Required-ish fields mentioned in your guide excerpt:
// - employeeNoString
// - currentVerifyMode
// - attendanceStatus
// - statusValue
string? empStr = TryGetString(info, "employeeNoString", "employeeNo", "employeeNoStr");
int? employeeNo = null;
if (!string.IsNullOrWhiteSpace(empStr) && int.TryParse(empStr.Trim(), out var parsedEmp))
employeeNo = parsedEmp;
string? userIdentifier = string.IsNullOrWhiteSpace(empStr) ? null : empStr.Trim();
// Optional fields: card/door/reader vary by device & config.
string? cardNo = TryGetString(info, "cardNoString", "cardNo");
int doorNo = TryGetInt(info, "doorNo", 0);
int readerNo = TryGetInt(info, "readerNo", 0);
byte currentVerifyMode = 0;
var verifyMode = TryGetIntNullable(info, "currentVerifyMode");
if (verifyMode.HasValue)
currentVerifyMode = (byte)Math.Max(0, Math.Min(255, verifyMode.Value));
// Timestamp: best effort across common key names containing "Time".
DateTime ts;
if (!TryExtractStdAcsDateTime(info, out var parsedTs))
ts = DateTime.UtcNow;
else
ts = parsedTs;
// Infer method from currentVerifyMode (and minor-driven rules if any).
string method = AcsAttendanceParser.InferMethodFromAcsDetail(
rawMinor,
byCardReaderKind: 0,
byCurrentVerifyMode: currentVerifyMode,
eventNameFallback: eventName);
// Use existing success inference rules first; optionally refine using statusValue.
bool isSuccess = isSuccessByMinorRule;
if (!isSuccess)
{
var statusValue = TryGetIntNullable(info, "statusValue");
if (statusValue.HasValue)
isSuccess = statusValue.Value != 0;
}
attendanceEvent = new AttendanceEvent(
device.DeviceId,
device.Ip ?? "",
ts,
employeeNo,
userIdentifier,
string.IsNullOrWhiteSpace(cardNo) ? null : cardNo,
doorNo,
readerNo,
method,
eventName,
eventType,
"Historical",
isSuccess,
rawMajor,
rawMinor,
historySerialNo: 0);
return true;
}
private static string? TryGetString(Dictionary<string, object> d, params string[] keys)
{
foreach (var k in keys)
{
if (!d.TryGetValue(k, out var v) || v == null)
continue;
return v is string s ? s : v.ToString();
}
return null;
}
private static int TryGetInt(Dictionary<string, object> d, string key, int defaultValue)
{
if (!d.TryGetValue(key, out var v) || v == null)
return defaultValue;
if (v is int i) return i;
if (v is long l) return (int)l;
if (v is double dd) return (int)dd;
if (v is string s && int.TryParse(s, out var p)) return p;
return defaultValue;
}
private static int? TryGetIntNullable(Dictionary<string, object> d, string key)
{
if (!d.TryGetValue(key, out var v) || v == null)
return null;
if (v is int i) return i;
if (v is long l) return (int)l;
if (v is double dd) return (int)dd;
if (v is string s && int.TryParse(s, out var p)) return p;
return null;
}
private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt)
{
dt = default;
string[] candidateKeys =
{
"statusTime",
"verifyTime",
"attendanceTime",
"eventTime",
"time",
"statusTimeString",
"verifyTimeString"
};
foreach (var key in candidateKeys)
{
if (info.TryGetValue(key, out var v) && v != null)
{
if (TryParseStdAcsDateTimeValue(v, out dt))
return true;
}
}
foreach (var kv in info)
{
if (kv.Key != null && kv.Key.IndexOf("time", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (TryParseStdAcsDateTimeValue(kv.Value, out dt))
return true;
}
}
return false;
}
private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt)
{
dt = default;
if (value == null)
return false;
if (value is DateTime d)
{
dt = d;
return true;
}
if (value is long l)
{
try
{
// Heuristic: >10 digits => milliseconds.
if (l > 10_000_000_000L)
dt = DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime;
else
dt = DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime;
return true;
}
catch
{
return false;
}
}
if (value is int i)
return TryParseStdAcsDateTimeValue((long)i, out dt);
string s = value is string ss ? ss : value.ToString() ?? "";
if (string.IsNullOrWhiteSpace(s))
return false;
s = s.Trim();
string[] formats =
{
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.FFF'Z'",
"yyyy-MM-dd'T'HH:mm:sszzz",
"yyyy-MM-dd'T'HH:mm:ss.FFFzzz",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd"
};
if (DateTime.TryParseExact(
s,
formats,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out dt))
return true;
if (DateTime.TryParse(
s,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal,
out dt))
return true;
return false;
}
private static string TruncateForLog(string s, int maxChars)
{
if (string.IsNullOrEmpty(s))
return s ?? "";
if (s.Length <= maxChars)
return s;
return s.Substring(0, maxChars) + "...[truncated " + (s.Length - maxChars) + " chars]";
}
private static CHCNetSDK.NET_DVR_TIME ToDvrTime(DateTime local)
{
return new CHCNetSDK.NET_DVR_TIME
{
dwYear = local.Year,
dwMonth = local.Month,
dwDay = local.Day,
dwHour = local.Hour,
dwMinute = local.Minute,
dwSecond = local.Second
};
}
private static void PrepareAcsEventCfgPointer(IntPtr cfgPtr, int cfgSize)
{
var cfg = new CHCNetSDK.NET_DVR_ACS_EVENT_CFG();
cfg.sNetUser = new byte[CHCNetSDK.MAX_NAMELEN];
cfg.struRemoteHostAddr.Init();
var d = new CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL();
d.dwSize = (uint)Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL));
d.byCardNo = new byte[CHCNetSDK.ACS_CARD_NO_LEN];
d.byMACAddr = new byte[CHCNetSDK.MACADDR_LEN];
d.byRe2 = new byte[2];
d.byEmployeeNo = new byte[CHCNetSDK.NET_SDK_EMPLOYEE_NO_LEN];
d.byRes = new byte[64];
cfg.struAcsEventInfo = d;
cfg.byRes = new byte[61];
cfg.dwSize = (uint)cfgSize;
cfg.dwPicDataLen = 0;
cfg.pPicData = IntPtr.Zero;
Marshal.StructureToPtr(cfg, cfgPtr, false);
}
private bool ControlDoor(string deviceId, uint action, out string error)
{
error = "";
if (string.IsNullOrWhiteSpace(deviceId))
{
error = "deviceId is required";
return false;
}
var session = FindSession(deviceId);
if (session == null)
{
error = "device not logged in: " + deviceId;
return false;
}
int doorIndex = session.Device.GatewayDoorIndex > 0 ? session.Device.GatewayDoorIndex : 1;
bool ok = EventByDeploy.CHCNetSDK.NET_DVR_ControlGateway(session.UserId, doorIndex, action);
if (!ok)
{
error = "NET_DVR_ControlGateway failed, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action + ", " + BuildSdkError("NET_DVR_ControlGateway");
_logger.Error(error);
return false;
}
_logger.Info("NET_DVR_ControlGateway succeeded, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action);
return true;
}
private void LogConfiguredTerminalProfile(HikvisionAttendanceWindowsService.DeviceConfig device)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(device.Model))
parts.Add("model=" + device.Model.Trim());
if (!string.IsNullOrWhiteSpace(device.FirmwareVersion))
parts.Add("firmware=" + device.FirmwareVersion.Trim());
if (!string.IsNullOrWhiteSpace(device.SerialNumber))
parts.Add("serial=" + device.SerialNumber.Trim());
if (!string.IsNullOrWhiteSpace(device.SubnetMask))
parts.Add("mask=" + device.SubnetMask.Trim());
if (!string.IsNullOrWhiteSpace(device.DefaultGateway))
parts.Add("gateway=" + device.DefaultGateway.Trim());
if (parts.Count > 0)
_logger.Info("Terminal profile (config): " + string.Join(", ", parts) + ".");
}
private static string FormatSdkSerial(byte[]? bytes)
{
if (bytes == null || bytes.Length == 0)
return "";
try
{
return Encoding.ASCII.GetString(bytes).TrimEnd('\0').Trim();
}
catch
{
return "";
}
}
private DeviceSession FindSession(string deviceId)
{
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
if (key.Length == 0)
return null;
foreach (var s in _sessions)
{
if (DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key)
return s;
}
return null;
}
private static void CopyUtf8(string value, byte[] target)
{
if (target == null || target.Length == 0 || string.IsNullOrEmpty(value))
return;
var src = Encoding.UTF8.GetBytes(value);
int n = Math.Min(src.Length, target.Length);
Buffer.BlockCopy(src, 0, target, 0, n);
}
// SDK callback: do not block (only enqueue). Signature must match Common.CHCNetSDK.MSGCallBack (same module as Init).
private void AlarmCallback(int lCommand, ref Common.CHCNetSDK.NET_DVR_ALARMER pAlarmer, IntPtr pAlarmInfo, uint dwBufLen, IntPtr pUser)
{
try
{
if (lCommand != Common.CHCNetSDK.COMM_ALARM_ACS)
return;
var acsAlarm = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_ACS_ALARM_INFO>(pAlarmInfo);
var eventName = MapAcsEventName(acsAlarm.dwMajor, acsAlarm.dwMinor);
if (string.IsNullOrWhiteSpace(eventName))
eventName = "MAJOR_" + acsAlarm.dwMajor + "_MINOR_" + acsAlarm.dwMinor;
uint rawMajor = acsAlarm.dwMajor;
uint rawMinor = acsAlarm.dwMinor;
var info = acsAlarm.struAcsEventInfo;
uint empNo = info.dwEmployeeNo;
string cardNo = DecodeCardNo(info.byCardNo);
bool hasEmployee = empNo != 0;
bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0";
if (!hasEmployee && !hasCard)
return;
DateTime ts = FromSdkTime(acsAlarm.struTime);
bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(rawMajor, rawMinor, eventName);
string method = AcsAttendanceParser.InferMethodFromAcsDetail(rawMinor, info.byCardReaderKind, 0, eventName);
string deviceId = "unknown";
string deviceIp = pAlarmer.sDeviceIP ?? "unknown";
foreach (var s in _sessions)
{
if (s.UserId == pAlarmer.lUserID)
{
deviceId = s.Device.DeviceId;
deviceIp = s.Device.Ip ?? deviceIp;
break;
}
}
string eventType = AcsAttendanceParser.MapMajorCategory(rawMajor) + "/" + rawMinor.ToString("X");
var attendanceEvent = new AttendanceEvent(
deviceId,
deviceIp,
ts,
hasEmployee ? (int?)((int)empNo) : null,
null,
hasCard ? cardNo : null,
(int)info.dwDoorNo,
(int)info.dwCardReaderNo,
method,
eventName,
eventType,
"Live",
isSuccess,
rawMajor,
rawMinor,
0);
EnqueueAttendance(attendanceEvent, "LIVE ACS event");
}
catch (Exception ex)
{
_logger.Error("AlarmCallback parse error", ex);
}
}
private string MapAcsEventName(uint dwMajor, uint dwMinor)
{
var logInfo = new EventByDeploy.CHCNetSDK.NET_DVR_LOG_V30();
logInfo.dwMajorType = dwMajor;
logInfo.dwMinorType = dwMinor;
char[] csTmp = new char[256];
if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_ALARM)
TypeMap.AlarmMinorTypeMap(logInfo, csTmp);
else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_OPERATION)
TypeMap.OperationMinorTypeMap(logInfo, csTmp);
else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_EXCEPTION)
TypeMap.ExceptionMinorTypeMap(logInfo, csTmp);
else if (logInfo.dwMajorType == EventByDeploy.CHCNetSDK.MAJOR_EVENT)
TypeMap.EventMinorTypeMap(logInfo, csTmp);
return new string(csTmp).TrimEnd('\0').Trim();
}
private bool TryBuildAttendanceFromAcsCfg(
DeviceSession session,
ref CHCNetSDK.NET_DVR_ACS_EVENT_CFG cfg,
out AttendanceEvent ev)
{
ev = null!;
var detail = cfg.struAcsEventInfo;
string eventName = MapAcsEventName(cfg.dwMajor, cfg.dwMinor);
if (string.IsNullOrWhiteSpace(eventName))
eventName = "MAJOR_" + cfg.dwMajor + "_MINOR_" + cfg.dwMinor;
string userIdStr = DecodeEmployeeNo(detail.byEmployeeNo);
uint empNum = detail.dwEmployeeNo;
string cardNo = DecodeCardNo(detail.byCardNo);
bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0";
bool hasEmpNum = empNum != 0;
bool hasEmpStr = !string.IsNullOrWhiteSpace(userIdStr);
if (!hasCard && !hasEmpNum && !hasEmpStr)
return false;
int? employeeNo = null;
if (hasEmpNum)
employeeNo = (int)empNum;
else if (hasEmpStr && int.TryParse(userIdStr, out var parsed))
employeeNo = parsed;
DateTime ts = FromSdkTime(cfg.struTime);
bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(cfg.dwMajor, cfg.dwMinor, eventName);
string method = AcsAttendanceParser.InferMethodFromAcsDetail(
cfg.dwMinor,
detail.byCardReaderKind,
detail.byCurrentVerifyMode,
eventName);
string eventType = AcsAttendanceParser.MapMajorCategory(cfg.dwMajor) + "/" + cfg.dwMinor.ToString("X");
ev = new AttendanceEvent(
session.Device.DeviceId,
session.Device.Ip ?? "",
ts,
employeeNo,
hasEmpStr ? userIdStr : null,
hasCard ? cardNo : null,
(int)detail.dwDoorNo,
(int)detail.dwCardReaderNo,
method,
eventName,
eventType,
"Historical",
isSuccess,
cfg.dwMajor,
cfg.dwMinor,
detail.dwSerialNo);
return true;
}
private void EnqueueAttendance(AttendanceEvent attendanceEvent, string logContext)
{
if (_config.AutoDoorControlOnSuccess && attendanceEvent.IsSuccess && attendanceEvent.Source == "Live")
{
TriggerAutoDoor(attendanceEvent.DeviceId);
}
if (Volatile.Read(ref _queueSize) >= _queueMax)
{
_logger.Warn(logContext + ": queue full, dropping event for device " + attendanceEvent.DeviceId);
return;
}
if (string.Equals(attendanceEvent.Source, "Historical", StringComparison.OrdinalIgnoreCase))
{
var key = attendanceEvent.DedupeKey;
if (!_dedupeKeys.TryAdd(key, 0))
{
_logger.Info("Dedupe skip (historical): " + key);
return;
}
if (_dedupeKeys.Count > DedupeMaxEntries)
{
_dedupeKeys.Clear();
_logger.Warn("Dedupe cache cleared (size limit).");
}
}
_queue.Enqueue(attendanceEvent);
Interlocked.Increment(ref _queueSize);
_queueSignal.Release();
_logger.Info(logContext + ": device=" + attendanceEvent.DeviceId + ", emp=" + attendanceEvent.EmployeeNo +
", userId=" + attendanceEvent.UserIdentifier + ", card=" + attendanceEvent.CardNo +
", door=" + attendanceEvent.DoorNo + ", reader=" + attendanceEvent.ReaderNo +
", method=" + attendanceEvent.AttendanceMethod + ", success=" + attendanceEvent.IsSuccess +
", major/minor=" + attendanceEvent.RawMajor + "/" + attendanceEvent.RawMinor);
}
private static string DecodeEmployeeNo(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
return "";
try
{
return Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim();
}
catch
{
return "";
}
}
private static DateTime FromSdkTime(EventByDeploy.CHCNetSDK.NET_DVR_TIME t)
{
// Sdk structs use int; guard against 0/invalid timestamps.
if (t.dwYear <= 1900)
{
return DateTime.UtcNow;
}
return new DateTime(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond);
}
private static string DecodeCardNo(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
return "";
try
{
return Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim();
}
catch
{
return "";
}
}
private void QueueWriterLoop(CancellationToken token)
{
StreamWriter sw = null;
try
{
// Append continuously; flush per event to keep “real-time” feel.
sw = new StreamWriter(new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite));
sw.AutoFlush = true;
while (!token.IsCancellationRequested)
{
_queueSignal.Wait(token);
AttendanceEvent ev;
while (_queue.TryDequeue(out ev))
{
Interlocked.Decrement(ref _queueSize);
lock (_csvWriteLock)
{
sw.WriteLine(ToCsvLine(ev));
}
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
AppendAttendanceToTextFileSafely(ev);
WriteAttendanceToDatabase(ev);
}
}
}
catch (OperationCanceledException)
{
// expected
}
catch (Exception ex)
{
_logger.Error("QueueWriterLoop failed", ex);
}
finally
{
try { if (sw != null) sw.Dispose(); } catch { /* ignore */ }
}
}
private void ExportLoop(CancellationToken token)
{
// Simple exporter placeholder: periodically copies the latest CSV for HR/payroll integration.
while (!token.IsCancellationRequested)
{
try
{
token.WaitHandle.WaitOne(TimeSpan.FromMinutes(1));
if (token.IsCancellationRequested)
break;
lock (_csvWriteLock)
{
if (File.Exists(_csvPath))
{
File.Copy(_csvPath, _exportPath, overwrite: true);
File.Copy(_csvPath, _hrExportPath, overwrite: true);
_logger.Info("ExportLoop: attendance_events.csv copied to attendance_export.csv and HR export path.");
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.Error("ExportLoop failed", ex);
}
}
}
private static string ToCsvLine(AttendanceEvent ev)
{
string Q(string? s)
{
if (s == null) return "\"\"";
s = s.Replace("\"", "\"\"");
return "\"" + s + "\"";
}
return string.Join(",",
Q(ev.DeviceId),
Q(ev.DeviceIp),
Q(ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")),
ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : "",
Q(ev.UserIdentifier),
Q(ev.CardNo),
ev.DoorNo.ToString(),
ev.ReaderNo.ToString(),
Q(ev.AttendanceMethod),
Q(ev.EventName),
Q(ev.EventType),
Q(ev.Source),
ev.IsSuccess ? "1" : "0",
ev.RawMajor.ToString(),
ev.RawMinor.ToString());
}
private void AppendAttendanceCsvLineSafely(string csvLine)
{
using (var fs = new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
using (var writer = new StreamWriter(fs, Encoding.UTF8))
{
writer.WriteLine(csvLine);
}
}
public void Dispose()
{
try { if (_cts != null) _cts.Cancel(); } catch { /* ignore */ }
}
/// <summary>Human-readable one-line record. Re-enable SQL path via EnableDatabasePersistence + SqlConnectionString.</summary>
private void AppendAttendanceToTextFileSafely(AttendanceEvent ev)
{
try
{
var line = FormatAttendanceTextLine(ev);
lock (_attendanceTextLock)
{
File.AppendAllText(_attendanceTextPath, line + Environment.NewLine, Encoding.UTF8);
}
_logger.Info("Attendance text file: write OK path=" + _attendanceTextPath + " Source=" + ev.Source);
}
catch (Exception ex)
{
_logger.Error("Attendance text file: write FAILED path=" + _attendanceTextPath + " Source=" + ev.Source, ex);
}
}
private static string FormatAttendanceTextLine(AttendanceEvent ev)
{
string emp = ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : "";
return "[" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + "] " +
"Device=" + TxtToken(ev.DeviceId) +
" IP=" + TxtToken(ev.DeviceIp) +
" EmployeeId=" + TxtToken(emp) +
" UserIdentifier=" + TxtToken(ev.UserIdentifier) +
" CardNo=" + TxtToken(ev.CardNo) +
" Method=" + TxtToken(ev.AttendanceMethod) +
" Event=" + TxtToken(ev.EventName) +
" Door=" + ev.DoorNo +
" Reader=" + ev.ReaderNo +
" Success=" + (ev.IsSuccess ? "true" : "false") +
" Major=" + ev.RawMajor +
" Minor=" + ev.RawMinor +
" Source=" + TxtToken(ev.Source);
}
private static string TxtToken(string? value)
{
if (string.IsNullOrEmpty(value))
return "";
if (value.IndexOf(' ') >= 0 || value.IndexOf('=') >= 0)
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
private void TryUpdateMachineRuntimeState(string machineId, string status, DateTime? lastSyncDateUtc, int? totalUsers)
{
if (!_config.EnableDbIntegration || _attendanceMachineRepository == null || string.IsNullOrWhiteSpace(machineId))
return;
DateTime? lastSyncLocal = null;
if (lastSyncDateUtc.HasValue)
lastSyncLocal = NormalizeForDbLocalTime(lastSyncDateUtc.Value);
_attendanceMachineRepository.UpdateMachineSyncState(machineId, status, lastSyncLocal, totalUsers, out var err);
if (!string.IsNullOrWhiteSpace(err))
_logger.Warn("Machine state update failed machine_ip=" + machineId + " err=" + err);
}
private string ResolveMachineIpForDbLookup(string deviceId)
{
var bySession = _sessions.FirstOrDefault(s =>
string.Equals(DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
if (bySession != null && !string.IsNullOrWhiteSpace(bySession.Device.Ip))
return bySession.Device.Ip.Trim();
var byRuntime = (ResolveRuntimeDevices() ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>())
.FirstOrDefault(d => string.Equals(DeviceIdentity.CanonicalLookupKey(d.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
if (byRuntime != null && !string.IsNullOrWhiteSpace(byRuntime.Ip))
return byRuntime.Ip.Trim();
return "";
}
private static DateTime NormalizeForDbLocalTime(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
return value.ToLocalTime();
return value;
}
private bool WriteAttendanceToDatabase(AttendanceEvent ev)
{
if (_config.EnableDbIntegration && _config.EnableAttendanceDbPersistence && _attendanceLogRepository != null)
{
var acNo = ev.EmployeeNo.HasValue
? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture)
: (ev.UserIdentifier ?? "");
var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0;
var ok = _attendanceLogRepository.UpsertAttendance(
acNo,
ev.Timestamp,
0,
ev.DeviceId ?? "",
inOutTypeId,
ev.DeviceIp ?? "",
ev.Timestamp.Date,
out var err);
if (!ok)
{
_logger.Error("WriteAttendanceToDatabase MySQL(REPLACE) failed: " + err);
return false;
}
_logger.Info(
"[AttendanceInsert] machine_id=" + (ev.DeviceId ?? "") +
" ip=" + (ev.DeviceIp ?? "") +
" emp_no=" + (ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) : "") +
" checktime=" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
" rows=1");
return true;
}
if (!_config.EnableDatabasePersistence)
return true;
if (string.IsNullOrWhiteSpace(_config.SqlConnectionString))
return false;
try
{
using (var conn = new SqlConnection(_config.SqlConnectionString))
{
conn.Open();
// EventType column = SDK descriptive name (same as pre-change behavior). Add optional columns in DB as needed.
var sql = "INSERT INTO " + _config.AttendanceTableName + " " +
"(DeviceId, DeviceIp, EmployeeId, UserIdentifier, CardNo, EventType, AttendanceMethod, EventTimestamp, DoorNo, ReaderNo, IsSuccess, RawMajor, RawMinor, EventSource) " +
"VALUES (@DeviceId,@DeviceIp,@EmployeeId,@UserIdentifier,@CardNo,@EventType,@AttendanceMethod,@EventTimestamp,@DoorNo,@ReaderNo,@IsSuccess,@RawMajor,@RawMinor,@EventSource)";
using (var cmd = new SqlCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@DeviceId", (object)ev.DeviceId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DeviceIp", (object)ev.DeviceIp ?? DBNull.Value);
cmd.Parameters.AddWithValue("@EmployeeId", (object)ev.EmployeeNo ?? DBNull.Value);
cmd.Parameters.AddWithValue("@UserIdentifier", (object)ev.UserIdentifier ?? DBNull.Value);
cmd.Parameters.AddWithValue("@CardNo", (object)ev.CardNo ?? DBNull.Value);
cmd.Parameters.AddWithValue("@EventType", (object)ev.EventName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@AttendanceMethod", (object)ev.AttendanceMethod ?? DBNull.Value);
cmd.Parameters.AddWithValue("@EventTimestamp", ev.Timestamp);
cmd.Parameters.AddWithValue("@DoorNo", ev.DoorNo);
cmd.Parameters.AddWithValue("@ReaderNo", ev.ReaderNo);
cmd.Parameters.AddWithValue("@IsSuccess", ev.IsSuccess);
cmd.Parameters.AddWithValue("@RawMajor", ev.RawMajor);
cmd.Parameters.AddWithValue("@RawMinor", ev.RawMinor);
cmd.Parameters.AddWithValue("@EventSource", (object)ev.Source ?? DBNull.Value);
int rows = cmd.ExecuteNonQuery();
_logger.Info(
"[AttendanceInsert] machine_id=" + (ev.DeviceId ?? "") +
" ip=" + (ev.DeviceIp ?? "") +
" emp_no=" + (ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) : "") +
" checktime=" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
" rows=" + rows);
}
}
return true;
}
catch (Exception ex)
{
_logger.Error("WriteAttendanceToDatabase failed (extend table: UserIdentifier NVARCHAR, ReaderNo INT, EventSource NVARCHAR)", ex);
return false;
}
}
private void TriggerAutoDoor(string deviceId)
{
Task.Run(() =>
{
string error;
if (!ControlDoor(deviceId, 1, out error))
return;
_logger.Info("AUTO_DOOR: opened gateway after successful access, deviceId=" + deviceId +
", delayCloseSec=" + _config.AutoDoorCloseDelaySeconds);
if (_config.AutoDoorCloseDelaySeconds <= 0)
return;
try
{
Thread.Sleep(TimeSpan.FromSeconds(_config.AutoDoorCloseDelaySeconds));
ControlDoor(deviceId, 0, out error);
_logger.Info("AUTO_DOOR: close after delay, deviceId=" + deviceId);
}
catch (Exception ex)
{
_logger.Error("TriggerAutoDoor failed", ex);
}
});
}
private string BuildSdkError(string operation)
{
try
{
uint err = Common.CHCNetSDK.NET_DVR_GetLastError();
return operation + " errorCode=" + err + " (" + TranslateSdkErrorCode(err) + ")";
}
catch
{
return operation + " errorCode=unknown";
}
}
private void LogSdkFailure(string operation, uint errorCode, string? deviceId, string? ip, string? detail, bool isWarning)
{
var lines = new[]
{
"SDK operation failed",
"Operation: " + operation,
"DeviceId: " + (string.IsNullOrWhiteSpace(deviceId) ? "-" : deviceId),
"IP: " + (string.IsNullOrWhiteSpace(ip) ? "-" : ip),
"Reason: " + TranslateSdkErrorCode(errorCode),
"Original: " + operation + " errorCode=" + errorCode,
"Detail: " + (string.IsNullOrWhiteSpace(detail) ? "-" : detail)
};
var msg = string.Join(Environment.NewLine, lines);
if (isWarning)
_logger.Warn(msg);
else
_logger.Error(msg);
}
private void LogSdkFailureFromText(string operation, string sdkErrorText, string? deviceId, string? ip, bool isWarning)
{
if (TryExtractSdkErrorCodeFromText(sdkErrorText, out var code))
{
LogSdkFailure(operation, code, deviceId, ip, sdkErrorText, isWarning);
return;
}
var lines = new[]
{
"SDK operation failed",
"Operation: " + operation,
"DeviceId: " + (string.IsNullOrWhiteSpace(deviceId) ? "-" : deviceId),
"IP: " + (string.IsNullOrWhiteSpace(ip) ? "-" : ip),
"Reason: Unable to parse SDK error code",
"Original: " + sdkErrorText,
"Detail: " + sdkErrorText
};
var msg = string.Join(Environment.NewLine, lines);
if (isWarning)
_logger.Warn(msg);
else
_logger.Error(msg);
}
private static bool TryExtractSdkErrorCodeFromText(string text, out uint code)
{
code = 0;
if (string.IsNullOrWhiteSpace(text))
return false;
var m = System.Text.RegularExpressions.Regex.Match(text, @"\berr(orCode)?\s*=?\s*(\d+)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!m.Success)
return false;
return uint.TryParse(m.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out code);
}
private static string TranslateSdkErrorCode(uint code)
{
switch (code)
{
case 0: return "No error";
case 1: return "Password error";
case 2: return "No sufficient privilege";
case 3: return "SDK not initialized (NET_DVR_NOINIT)";
case 4: return "Channel error";
case 5: return "No such user";
case 6: return "Version mismatch";
case 7: return "Connection failed (network/device unreachable)";
case 8: return "Function not supported";
case 9: return "Illegal parameter";
case 10: return "Channel occupied";
case 11: return "SDK XML config error";
case 17: return "Parameter error";
case 23: return "No enough memory";
case 29: return "No permission";
case 41: return "Operation timeout";
case 72: return "Data send failed";
case 73: return "Data receive failed";
case 84: return "Create socket failed";
case 109: return "Network timeout / no response from device";
default: return "Unknown SDK error";
}
}
private sealed class DeviceSession
{
public DeviceSession(HikvisionAttendanceWindowsService.DeviceConfig device, int userId, int alarmHandle)
{
Device = device;
UserId = userId;
AlarmHandle = alarmHandle;
}
public HikvisionAttendanceWindowsService.DeviceConfig Device { get; private set; }
public int UserId { get; set; }
public int AlarmHandle { get; set; }
}
private sealed class AttendanceEvent
{
public AttendanceEvent(
string deviceId,
string deviceIp,
DateTime timestamp,
int? employeeNo,
string? userIdentifier,
string? cardNo,
int doorNo,
int readerNo,
string attendanceMethod,
string eventName,
string eventType,
string source,
bool isSuccess,
uint rawMajor,
uint rawMinor,
uint historySerialNo)
{
DeviceId = deviceId;
DeviceIp = deviceIp;
Timestamp = timestamp;
EmployeeNo = employeeNo;
UserIdentifier = userIdentifier;
CardNo = cardNo;
DoorNo = doorNo;
ReaderNo = readerNo;
AttendanceMethod = attendanceMethod;
EventName = eventName;
EventType = eventType;
Source = source;
IsSuccess = isSuccess;
RawMajor = rawMajor;
RawMinor = rawMinor;
HistorySerialNo = historySerialNo;
}
public string DeviceId { get; }
public string DeviceIp { get; }
public DateTime Timestamp { get; }
public int? EmployeeNo { get; }
public string? UserIdentifier { get; }
public string? CardNo { get; }
public int DoorNo { get; }
public int ReaderNo { get; }
public string AttendanceMethod { get; }
public string EventName { get; }
public string EventType { get; }
public string Source { get; }
public bool IsSuccess { get; }
public uint RawMajor { get; }
public uint RawMinor { get; }
public uint HistorySerialNo { get; }
public string DedupeKey =>
Source + "|" + DeviceId + "|" + (HistorySerialNo != 0
? HistorySerialNo.ToString()
: Timestamp.ToString("yyyyMMddHHmmss") + "|" + RawMajor + "|" + RawMinor) + "|" +
(EmployeeNo?.ToString() ?? "") + "|" + (UserIdentifier ?? "") + "|" + (CardNo ?? "") + "|" +
DoorNo + "|" + ReaderNo;
}
}