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.Threading; using System.Threading.Tasks; using System.Web.Script.Serialization; using EventByDeploy; using Common; using HikvisionAttendanceService.Interop; using CHCNetSDK = EventByDeploy.CHCNetSDK; namespace HikvisionAttendanceService; internal sealed class HikvisionAttendanceManager : IDisposable { private const string BuildMarker = "CFGDIAG_20260331_1"; private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config; private readonly HikvisionAttendanceWindowsService.FileLogger _logger; private readonly ConcurrentQueue _queue = new ConcurrentQueue(); 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 _sessions = new List(); private CancellationTokenSource _cts; private Task _queueWriterTask; private Task _exportTask; private Common.CHCNetSDK.MSGCallBack _callbackDelegate; private readonly ConcurrentDictionary _dedupeKeys = new ConcurrentDictionary(); 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); 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); } } /// Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch. 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(); _logger.Error("NET_DVR_Init failed, error=" + err); 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."); if (_config.HistoricalFetchIntervalMinutes > 0 && _sessions.Count > 0) { _logger.Info("Scheduled historical fetch enabled: every " + _config.HistoricalFetchIntervalMinutes + " min, lookback " + _config.HistoricalFetchLookbackMinutes + " min."); _ = Task.Run(() => HistoricalSchedulerLoop(_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."); } } /// Fetches stored ACS events from the device for the given local time range and enqueues them into the same pipeline. public Task FetchAttendanceRecordsAsync(string deviceId, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken = default) { return Task.Run(() => { DateTime? lastEventTimestamp; int n = FetchAttendanceRecordsCore(deviceId, fromLocal, toLocal, cancellationToken, out lastEventTimestamp); // CLI/manual fetches should also advance the last-sync cursor, // otherwise incremental sync won't work until the scheduled loop runs. string filePath = 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)") + ", filePath=" + filePath); if (n > 0 && lastEventTimestamp.HasValue) { WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value); _logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId + ", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss")); } 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(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(); 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(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(); 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; } /// /// Exports face + fingerprint templates for one SDK user key ( is written to device as card / employee string). /// Uses NET_DVR_GetDeviceConfig with NET_DVR_GET_FACE_PARAM_CFG and NET_DVR_GET_FINGERPRINT_PARAM (per-finger 1..10). /// 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 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(); } 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 FetchAllUserCardNosStdXml( int sessionUserId, int pageSize, int maxUsers, out string listError, CancellationToken cancellationToken) { listError = ""; var all = new HashSet(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 ExtractUserCardNosFromStdXml(string responseJson) { var result = new List(); 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(StringComparer.OrdinalIgnoreCase); var keySet = new HashSet(StringComparer.OrdinalIgnoreCase) { "employeeNo", "employeeNoString", "cardNo", "cardNoString", "employeeId", "userId" }; var stack = new Stack(); stack.Push(root); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary 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(); _logger.Error("NET_DVR_SetDVRMessageCallBack_V50 failed (Common), err=" + err + " (3=NET_DVR_NOINIT if wrong DLL instance)."); } else { _logger.Info("NET_DVR_SetDVRMessageCallBack_V50 succeeded (Common), index=0, delegate=MSGCallBack. build=" + BuildMarker); } } // Startup diagnostics: prove whether we have devices to login. if (_config.Devices == null) { _logger.Warn("StartDevices: config.Devices is NULL; skipping all device logins (active sessions will remain 0)."); return; } _logger.Info("StartDevices: devicesToLoginCount=" + _config.Devices.Count); if (_config.Devices.Count == 0) { _logger.Warn("StartDevices: Devices is empty; skipping all device logins (active sessions will remain 0)."); return; } for (int i = 0; i < _config.Devices.Count; i++) { var d = _config.Devices[i]; _logger.Info("StartDevices: loadedDevice[" + i + "]: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port + " Username=\"" + (d.Username ?? "") + "\""); } foreach (var device in _config.Devices) { _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(); _logger.Error("NET_DVR_Login_V30 failed for " + device.DeviceId + " (" + device.Ip + ":" + device.Port + "), err=" + err); 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(); _logger.Error("NET_DVR_SetupAlarmChan_V50 failed for " + device.DeviceId + ", err=" + err + ". Session still recorded for login-only APIs (e.g. historical fetch)."); } 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)" : "")); } } private void StopDevices() { foreach (var s in _sessions.ToArray()) { try { if (s.AlarmHandle >= 0) { if (!Common.CHCNetSDK.NET_DVR_CloseAlarmChan_V30(s.AlarmHandle)) { _logger.Warn("NET_DVR_CloseAlarmChan_V30 failed for " + s.Device.DeviceId + ", err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); } 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); } } catch (Exception ex) { _logger.Error("Logout failed for " + s.Device.DeviceId, ex); } } _sessions.Clear(); } private async Task HistoricalSchedulerLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromMinutes(_config.HistoricalFetchIntervalMinutes), token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } var to = DateTime.Now; // Fallback window used only for the first sync (when last-sync file is missing) or if last-sync is unreadable. var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes); if (_config.HistoricalFetchLookbackMinutes <= 0) fallbackFrom = to.AddDays(-1); foreach (var s in _sessions.ToArray()) { try { var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason); DateTime from; if (lastSync.HasValue) from = lastSync.Value.AddSeconds(1); else from = fallbackFrom; 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; int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp); _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)")); // Update last sync only when we actually parsed at least one event and we have a usable event timestamp. if (n > 0 && lastEventTimestamp.HasValue) WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value); } catch (Exception ex) { _logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex); } } } } 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 = ""; 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) { 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) { lastEventTimestamp = null; 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")); // 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); 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) { _logger.Error("NET_DVR_StartRemoteConfig(NET_DVR_GET_ACS_EVENT, Common) failed, err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); 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(cfgPtr); total++; if (TryBuildAttendanceFromAcsCfg(session, ref cfg, out var ev)) { EnqueueAttendance(ev, "Historical fetch parsed"); parsedOk++; 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) { _logger.Error("NET_DVR_GetNextRemoteConfig failed status, err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); handle = -1; break; } _logger.Warn("NET_DVR_GetNextRemoteConfig unknown status=" + status + ", err=" + Common.CHCNetSDK.NET_DVR_GetLastError()); 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); 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; } /// /// 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. /// private int TryStdXmlAcsFetchAndEnqueue( DeviceSession session, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken, out bool attemptedStdXml, out DateTime? lastEventTimestamp) { attemptedStdXml = false; lastEventTimestamp = null; 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; 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)) _logger.Warn("STDXMLConfig step1 SDK error: " + e1); 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)) _logger.Warn("STDXMLConfig step2 SDK error: " + e2); 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)) _logger.Warn("STDXMLConfig step3 SDK error: " + e3); // 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)) _logger.Warn("STDXMLConfig step5 SDK error: " + eTotalNum); _logger.Info("STDXMLConfig step5 parsedResponseStatus: " + ParseStdXmlResponseStatus(rTotalNum)); cancellationToken.ThrowIfCancellationRequested(); // Step 6: build JSON_AcsEventCond and POST. string jsonAcsEventCond = BuildJsonAcsEventCond(searchId, searchResultPosition, maxResults, major, minor, startTime, endTime); string acsEventUri = "/ISAPI/AccessControl/AcsEvent?format=json"; string acsEventRequestUrl = "POST " + acsEventUri; _logger.Info("STDXMLConfig step6 requestUrl=" + acsEventRequestUrl + " 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)) _logger.Warn("STDXMLConfig step6 SDK error: " + eAcsEvent); _logger.Info("STDXMLConfig step6 parsedResponseStatus: " + ParseStdXmlResponseStatus(rAcsEvent)); cancellationToken.ThrowIfCancellationRequested(); // Step 10 (goal): map returned fields into our attendance pipeline. var acsEventInfoList = ExtractAcsEventInfoList(rAcsEvent); _logger.Info("STDXMLConfig extracted events: " + acsEventInfoList.Count); string eventName = MapAcsEventName(major, minor); if (string.IsNullOrWhiteSpace(eventName)) eventName = "MAJOR_" + major + "_MINOR_" + minor; string eventType = AcsAttendanceParser.MapMajorCategory(major) + "/" + minor.ToString("X"); // Keep success inference consistent with existing pipeline rules. bool isSuccessByMinorRule = AcsAttendanceParser.ResolveIsSuccess(major, minor, eventName); int total = 0; int parsedOk = 0; int parseFail = 0; DateTime? maxEventTs = null; foreach (var info in acsEventInfoList) { total++; if (!TryBuildAttendanceFromStdAcsInfo(session.Device, info, major, minor, eventName, eventType, isSuccessByMinorRule, out var ev)) { parseFail++; continue; } EnqueueAttendance(ev, "Historical STDXMLConfig parsed"); parsedOk++; if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value) maxEventTs = ev.Timestamp; } _logger.Info("STDXMLConfig fetch DONE device=" + session.Device.DeviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail); attemptedStdXml = parsedOk > 0; 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; return 0; } } 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(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(); 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(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(); } } 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 17 // 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 items, out string parseError) { items = new List(); 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(); stack.Push(root); var candidateFingerIds = new HashSet(); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary d) { foreach (var kv in d) { if (kv.Value is Dictionary 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 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*(?-?\\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*\"(?[^\"]*)\"", 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*\"(?[^\"]*)\"", 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*(?-?\\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 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 fingerprints, out string debug) { fingerprints = new List(); 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(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(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(); } 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 17 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*\"?(?[^;\\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 parts, out string parseError) { parts = new List(); 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(); 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*(?[^;\\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*\"?(?[^\";\\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 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(); stack.Push(root); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary d) { foreach (var kv in d) { if (kv.Value is Dictionary 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 fingerprints, out string debug) { fingerprints = new List(); 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 { "{ \"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 { 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 candidates, out string parseError) { candidates = new List(); 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(); stack.Push(root); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary 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 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 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 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(); stack.Push(root); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary d) { foreach (var kv in d) { if (kv.Value is Dictionary 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 { "{ " + "\"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 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); 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 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; 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") + " ISAPI export face templates"); fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export fingerprint templates"); _logger.Info("ExportAllTemplates ISAPI: device=" + deviceId + ", discoveredUsers=" + cardNos.Count + ", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr)); 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; 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"; } 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); } // Fingerprint flow: capability-first, then FingerPrintDownload family. ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/FingerPrintCfg/capabilities?format=json", "fingerprint.FingerPrintCfg", fpSw); var fpItemsDoc = new List(); 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 { 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)); } 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); 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*\"(?[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (m.Success) return m.Groups["v"].Value; return "(unparsed)"; } } private static bool TryFindString(object obj, IEnumerable keys, out string value) { value = ""; var stack = new Stack(); stack.Push(obj); var keySet = new HashSet(keys, StringComparer.OrdinalIgnoreCase); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary 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 keys, out int value) { value = 0; var stack = new Stack(); stack.Push(obj); var keySet = new HashSet(keys, StringComparer.OrdinalIgnoreCase); while (stack.Count > 0) { var cur = stack.Pop(); if (cur is Dictionary 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> ExtractAcsEventInfoList(string responseJson) { var result = new List>(); 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 d && d.TryGetValue("AcsEvent", out var acsObj) && acsObj is Dictionary acsDict) { if (acsDict.TryGetValue("InfoList", out var infoListObj) && infoListObj is object[] arr) { foreach (var it in arr) { if (it is Dictionary itemDict) result.Add(itemDict); } if (result.Count > 0) return result; } } // Recursive best-effort fallback. var stack = new Stack(); stack.Push(root); while (stack.Count > 0 && result.Count == 0) { var cur = stack.Pop(); if (cur is Dictionary 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 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 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 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 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 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 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(); 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(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); sw.WriteLine(ToCsvLine(ev)); 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()); } public void Dispose() { try { if (_cts != null) _cts.Cancel(); } catch { /* ignore */ } } /// Human-readable one-line record. Re-enable SQL path via EnableDatabasePersistence + SqlConnectionString. 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 WriteAttendanceToDatabase(AttendanceEvent ev) { if (!_config.EnableDatabasePersistence) return; if (string.IsNullOrWhiteSpace(_config.SqlConnectionString)) return; 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); cmd.ExecuteNonQuery(); } } } catch (Exception ex) { _logger.Error("WriteAttendanceToDatabase failed (extend table: UserIdentifier NVARCHAR, ReaderNo INT, EventSource NVARCHAR)", ex); } } 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; } catch { return operation + " errorCode=unknown"; } } 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; } }