6194 lines
255 KiB
C#
6194 lines
255 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Concurrent;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Data.SqlClient;
|
|
using System.Text;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Web.Script.Serialization;
|
|
using EventByDeploy;
|
|
using Common;
|
|
using HikvisionAttendanceService.Interop;
|
|
using HikvisionAttendanceService.Data;
|
|
using CHCNetSDK = EventByDeploy.CHCNetSDK;
|
|
|
|
namespace HikvisionAttendanceService;
|
|
|
|
internal sealed partial class HikvisionAttendanceManager : IDisposable
|
|
{
|
|
private const string BuildMarker = "CFGDIAG_20260331_1";
|
|
private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config;
|
|
private readonly HikvisionAttendanceWindowsService.FileLogger _logger;
|
|
|
|
private readonly ConcurrentQueue<AttendanceEvent> _queue = new ConcurrentQueue<AttendanceEvent>();
|
|
private readonly SemaphoreSlim _queueSignal = new SemaphoreSlim(0, int.MaxValue);
|
|
private readonly int _queueMax = 1024;
|
|
private int _queueSize;
|
|
|
|
private readonly object _csvWriteLock = new object();
|
|
private readonly object _attendanceTextLock = new object();
|
|
private readonly string _csvPath;
|
|
private readonly string _exportPath;
|
|
private readonly string _hrExportPath;
|
|
private readonly string _attendanceTextPath;
|
|
|
|
private readonly List<DeviceSession> _sessions = new List<DeviceSession>();
|
|
private readonly List<HikvisionAttendanceWindowsService.DeviceConfig> _runtimeDevices = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
private readonly IHrmsDbConnectionFactory? _dbConnectionFactory;
|
|
private readonly IAttendanceMachineRepository? _attendanceMachineRepository;
|
|
private readonly IAttendanceLogRepository? _attendanceLogRepository;
|
|
private readonly IEmployeeLookupRepository? _employeeLookupRepository;
|
|
private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository;
|
|
private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository;
|
|
private readonly UnreachableDeviceTracker _unreachableDevices;
|
|
|
|
private DateTime _serviceStartedAt = DateTime.MinValue;
|
|
private string _stopReason = "";
|
|
|
|
private CancellationTokenSource _cts;
|
|
private Task _queueWriterTask;
|
|
private Task _exportTask;
|
|
private Common.CHCNetSDK.MSGCallBack _callbackDelegate;
|
|
|
|
private readonly ConcurrentDictionary<string, byte> _dedupeKeys = new ConcurrentDictionary<string, byte>();
|
|
private const int DedupeMaxEntries = 50_000;
|
|
|
|
public HikvisionAttendanceManager(
|
|
HikvisionAttendanceWindowsService.HikvisionServiceConfig config,
|
|
HikvisionAttendanceWindowsService.FileLogger logger)
|
|
{
|
|
_config = config;
|
|
_logger = logger;
|
|
_unreachableDevices = new UnreachableDeviceTracker(_logger);
|
|
|
|
Directory.CreateDirectory(_config.LogDirectory);
|
|
_csvPath = Path.Combine(_config.LogDirectory, "attendance_events.csv");
|
|
_exportPath = Path.Combine(_config.LogDirectory, "attendance_export.csv");
|
|
_hrExportPath = string.IsNullOrWhiteSpace(_config.HrExportPath)
|
|
? Path.Combine(_config.LogDirectory, "attendance_hr_sync.csv")
|
|
: _config.HrExportPath;
|
|
|
|
_attendanceTextPath = string.IsNullOrWhiteSpace(_config.AttendanceTextFilePath)
|
|
? Path.Combine(_config.LogDirectory, "attendance_records.txt")
|
|
: _config.AttendanceTextFilePath.Trim();
|
|
|
|
var textDir = Path.GetDirectoryName(Path.GetFullPath(_attendanceTextPath));
|
|
if (!string.IsNullOrEmpty(textDir))
|
|
Directory.CreateDirectory(textDir);
|
|
|
|
if (_config.EnableDbIntegration)
|
|
{
|
|
_dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config);
|
|
_attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory);
|
|
_attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory);
|
|
_employeeLookupRepository = new MySqlEmployeeLookupRepository(_dbConnectionFactory);
|
|
_attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory);
|
|
_attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory);
|
|
_logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked());
|
|
_logger.Diag("attendance", "DB connection configured: " + _dbConnectionFactory.BuildConnectionStringMasked());
|
|
}
|
|
|
|
EnsureCsvSchema();
|
|
}
|
|
|
|
private void EnsureCsvSchema()
|
|
{
|
|
const string header =
|
|
"DeviceId,DeviceIp,Timestamp,EmployeeNo,UserIdentifier,CardNo,DoorNo,ReaderNo,Method,EventName,EventType,Source,IsSuccess,RawMajor,RawMinor";
|
|
|
|
if (!File.Exists(_csvPath))
|
|
{
|
|
File.WriteAllText(_csvPath, header + Environment.NewLine);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var first = File.ReadLines(_csvPath).FirstOrDefault() ?? "";
|
|
if (first.IndexOf("ReaderNo", StringComparison.OrdinalIgnoreCase) < 0 ||
|
|
first.IndexOf("Source", StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
var bak = _csvPath + ".legacy_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".bak";
|
|
File.Copy(_csvPath, bak, overwrite: true);
|
|
File.WriteAllText(_csvPath, header + Environment.NewLine);
|
|
_logger.Warn("attendance CSV schema upgraded; previous file copied to " + bak);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("EnsureCsvSchema failed", ex);
|
|
}
|
|
}
|
|
|
|
private List<HikvisionAttendanceWindowsService.DeviceConfig> ResolveRuntimeDevices(bool emitBizStartupSummary = false)
|
|
{
|
|
_runtimeDevices.Clear();
|
|
var configDevices = _config.Devices ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
var credentialFallback = configDevices.FirstOrDefault(d =>
|
|
!string.IsNullOrWhiteSpace(d.Username) && !string.IsNullOrWhiteSpace(d.Password));
|
|
|
|
bool loadedFromDb = false;
|
|
if (_config.EnableDbIntegration && _config.EnableDbMachineLoading && _attendanceMachineRepository != null)
|
|
{
|
|
var dbMachines = _attendanceMachineRepository.GetActiveMachines("HIKVISION", out var dbErr);
|
|
if (!string.IsNullOrWhiteSpace(dbErr))
|
|
{
|
|
_logger.Warn("DB machine loading failed; fallback to config. err=" + dbErr);
|
|
}
|
|
else
|
|
{
|
|
var requestedScope = string.Equals(_config.MachineScopeMode, "SITE", StringComparison.OrdinalIgnoreCase) ? "SITE" : "CENTRAL";
|
|
var scopedIps = new HashSet<string>((_config.ScopedMachineIps ?? new List<string>()).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
|
|
var dbFetchedCount = dbMachines.Count;
|
|
if (requestedScope == "SITE")
|
|
{
|
|
dbMachines = dbMachines
|
|
.Where(m => !string.IsNullOrWhiteSpace(m.MachineIp) && scopedIps.Contains(m.MachineIp.Trim()))
|
|
.ToList();
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Scope, "ScopeMode=" + requestedScope +
|
|
(requestedScope == "SITE" ? " scopedIps=[" + string.Join(",", scopedIps) + "]" : " (all active Hikvision)") +
|
|
" fetchedFromDb=" + dbFetchedCount + " afterScopeFilter=" + dbMachines.Count);
|
|
|
|
int selected = 0;
|
|
int skippedNoCreds = 0;
|
|
foreach (var m in dbMachines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(m.MachineIp))
|
|
{
|
|
_logger.OpsWarn(OpsMarkers.Scope, "SKIP machine_id=" + (m.MachineId ?? "") + " reason=\"empty machine_ip\"");
|
|
continue;
|
|
}
|
|
|
|
var matchingCfg = configDevices.FirstOrDefault(x =>
|
|
string.Equals((x.Ip ?? "").Trim(), (m.MachineIp ?? "").Trim(), StringComparison.OrdinalIgnoreCase));
|
|
var creds = matchingCfg ?? credentialFallback;
|
|
if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.Password))
|
|
{
|
|
skippedNoCreds++;
|
|
_logger.OpsWarn(OpsMarkers.Scope, "SKIP machine_id=" + (m.MachineId ?? "") + " ip=" + m.MachineIp +
|
|
" reason=\"no username/password in config\"");
|
|
continue;
|
|
}
|
|
|
|
var deviceId = !string.IsNullOrWhiteSpace(m.MachineId)
|
|
? m.MachineId.Trim()
|
|
: (!string.IsNullOrWhiteSpace(m.MachineName) ? m.MachineName.Trim() : m.MachineIp.Trim());
|
|
var port = m.PortNumber > 0 ? m.PortNumber : (creds.Port > 0 ? creds.Port : 8000);
|
|
|
|
_runtimeDevices.Add(new HikvisionAttendanceWindowsService.DeviceConfig
|
|
{
|
|
DeviceId = deviceId,
|
|
Ip = m.MachineIp.Trim(),
|
|
Port = port,
|
|
Username = creds.Username,
|
|
Password = creds.Password,
|
|
FingerPrintReaderNo = creds.FingerPrintReaderNo,
|
|
FaceReaderNo = creds.FaceReaderNo,
|
|
GatewayDoorIndex = creds.GatewayDoorIndex,
|
|
Model = string.IsNullOrWhiteSpace(m.MachineName) ? (creds.Model ?? "") : m.MachineName,
|
|
SerialNumber = creds.SerialNumber,
|
|
FirmwareVersion = creds.FirmwareVersion,
|
|
SubnetMask = creds.SubnetMask,
|
|
DefaultGateway = creds.DefaultGateway
|
|
});
|
|
selected++;
|
|
_logger.Ops(OpsMarkers.Scope,
|
|
"SELECTED machine_id=" + deviceId +
|
|
" name=\"" + (m.MachineName ?? "") + "\"" +
|
|
" ip=" + m.MachineIp.Trim() +
|
|
" port=" + port +
|
|
" type=" + (m.MachineType ?? "HIKVISION"));
|
|
}
|
|
|
|
loadedFromDb = _runtimeDevices.Count > 0;
|
|
_logger.Ops(OpsMarkers.Scope, "SUMMARY fetched=" + dbFetchedCount +
|
|
" selected=" + selected + " skippedNoCredentials=" + skippedNoCreds);
|
|
|
|
// Business attendance log: scope once at service startup only (not every job cycle).
|
|
if (emitBizStartupSummary)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Scope : " + requestedScope,
|
|
"Machines in DB : " + dbFetchedCount,
|
|
"Machines selected : " + selected,
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_config.EnableDbMachineLoading && _config.PreferDbMachinesOverConfig && loadedFromDb)
|
|
{
|
|
_logger.Ops(OpsMarkers.Scope, "RuntimeSource=DB selectedCount=" + _runtimeDevices.Count);
|
|
return _runtimeDevices;
|
|
}
|
|
|
|
if (!loadedFromDb)
|
|
{
|
|
foreach (var d in configDevices)
|
|
{
|
|
_logger.Ops(OpsMarkers.Scope,
|
|
"SELECTED machine_id=" + (d.DeviceId ?? "") +
|
|
" name=\"" + (d.Model ?? "") + "\"" +
|
|
" ip=" + (d.Ip ?? "") +
|
|
" port=" + d.Port +
|
|
" type=HIKVISION");
|
|
}
|
|
|
|
if (emitBizStartupSummary)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Scope : CONFIG",
|
|
"Machines in DB : 0",
|
|
"Machines selected : " + configDevices.Count,
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
}
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Scope, "RuntimeSource=CONFIG selectedCount=" + configDevices.Count);
|
|
return configDevices;
|
|
}
|
|
|
|
/// <summary>Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch.</summary>
|
|
public async Task RunAsync(CancellationToken token)
|
|
{
|
|
Environment.CurrentDirectory = AppContext.BaseDirectory;
|
|
|
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
_queueWriterTask = Task.Run(() => QueueWriterLoop(_cts.Token), _cts.Token);
|
|
_exportTask = Task.Run(() => ExportLoop(_cts.Token), _cts.Token);
|
|
|
|
try
|
|
{
|
|
_serviceStartedAt = DateTime.Now;
|
|
_stopReason = "";
|
|
_logger.WriteBizServiceStarted();
|
|
_logger.Ops(OpsMarkers.Service, "START time=" + _serviceStartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
|
|
|
if (_config.EnableDbIntegration && _dbConnectionFactory != null)
|
|
{
|
|
if (_dbConnectionFactory.TryBuildConnectionString(out _, out var dbCsErr))
|
|
{
|
|
_logger.Biz(BizChannel.Attendance, "Database connection opened successfully.", "");
|
|
_logger.Diag("attendance", "DB connection OK: " + _dbConnectionFactory.BuildConnectionStringMasked());
|
|
}
|
|
else
|
|
{
|
|
var code = ExtractMysqlErrorCode(dbCsErr);
|
|
_logger.WriteBizDatabaseFailed(code, string.IsNullOrWhiteSpace(dbCsErr)
|
|
? "Unable to connect to MySQL server."
|
|
: dbCsErr);
|
|
_stopReason = "database connection failed";
|
|
_logger.OpsError(OpsMarkers.Service, "DB connection failed: " + dbCsErr);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!Common.CHCNetSDK.NET_DVR_Init())
|
|
{
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
_stopReason = "SDK init failed (error " + err + ")";
|
|
LogSdkFailure("NET_DVR_Init", err, null, null, "SDK initialization failed; service startup will stop.", isWarning: false);
|
|
_logger.OpsError(OpsMarkers.Service, "START FAILED reason=\"" + _stopReason + "\"");
|
|
return;
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Service, "SDK ready. Jobs: ATTENDANCE / TEMPLATE(DEVICE->DB) / USER_SYNC(DB->DEVICE + USER_DELETE)");
|
|
_logger.Diag("attendance", "HCNetSDK init OK; attendance text=\"" + _attendanceTextPath + "\"");
|
|
|
|
StartDevices();
|
|
|
|
var attendanceInterval = _config.AttendanceSyncIntervalMinutes > 0
|
|
? _config.AttendanceSyncIntervalMinutes
|
|
: _config.HistoricalFetchIntervalMinutes;
|
|
if (_config.EnableAttendanceSync && attendanceInterval > 0 && _sessions.Count > 0)
|
|
{
|
|
_logger.Ops(OpsMarkers.Service, "JOB ENABLED [ATTENDANCE] intervalMinutes=" + attendanceInterval +
|
|
" lookbackMinutes=" + _config.HistoricalFetchLookbackMinutes + " devices=" + _sessions.Count);
|
|
_ = Task.Run(() => HistoricalSchedulerLoop(_cts.Token), _cts.Token);
|
|
}
|
|
else
|
|
{
|
|
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [ATTENDANCE] EnableAttendanceSync=" + _config.EnableAttendanceSync +
|
|
" sessions=" + _sessions.Count);
|
|
}
|
|
|
|
if (_config.EnableTemplateFetch &&
|
|
_config.EnableTemplateDeviceToDbSync &&
|
|
_config.TemplateFetchIntervalHours > 0 &&
|
|
_sessions.Count > 0)
|
|
{
|
|
_logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DEVICE_TO_DB] intervalHours=" + _config.TemplateFetchIntervalHours +
|
|
" devices=" + _sessions.Count);
|
|
_ = Task.Run(() => TemplateFetchSchedulerLoop(_cts.Token), _cts.Token);
|
|
}
|
|
else
|
|
{
|
|
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DEVICE_TO_DB] EnableTemplateFetch=" + _config.EnableTemplateFetch +
|
|
" EnableTemplateDeviceToDbSync=" + _config.EnableTemplateDeviceToDbSync +
|
|
" sessions=" + _sessions.Count);
|
|
}
|
|
|
|
var hasUserSyncSource = !string.IsNullOrWhiteSpace(_config.SourceDeviceId) || !string.IsNullOrWhiteSpace(_config.SourceMachineIp);
|
|
var userSyncTargetCount = (_config.TargetDeviceIds?.Count ?? 0) + (_config.TargetMachineIps?.Count ?? 0);
|
|
var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0;
|
|
var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null &&
|
|
_attendanceMachineUserRepository != null;
|
|
var hasInitialDepartmentSyncRoute = _config.EnableInitialDepartmentSync && userSyncTargetCount > 0 &&
|
|
(_config.InitialSyncDepartmentIds?.Count ?? 0) > 0;
|
|
if ((_config.EnableUserSync || _config.EnableInitialDepartmentSync) &&
|
|
_config.SyncIntervalMinutes > 0 &&
|
|
(hasTemplateSyncRoute || hasDatabaseDeletionRoute || hasInitialDepartmentSyncRoute))
|
|
{
|
|
var sourceLabel = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? _config.SourceMachineIp : _config.SourceDeviceId;
|
|
_logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE]/[INITIAL_SYNC] intervalMinutes=" + _config.SyncIntervalMinutes +
|
|
" source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount +
|
|
" deletionByMachineId=" + hasDatabaseDeletionRoute +
|
|
" initialDepartmentSync=" + hasInitialDepartmentSyncRoute);
|
|
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
|
|
}
|
|
else
|
|
{
|
|
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync +
|
|
" EnableInitialDepartmentSync=" + _config.EnableInitialDepartmentSync +
|
|
" hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount +
|
|
" deletionByMachineId=" + hasDatabaseDeletionRoute);
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Service, "RUNNING sessions=" + _sessions.Count);
|
|
|
|
await Task.Delay(Timeout.Infinite, _cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_stopReason))
|
|
_stopReason = "cancel requested (Ctrl+C / Windows Service stop / debugger stop)";
|
|
_logger.Ops(OpsMarkers.Service, "STOP SIGNAL reason=\"" + _stopReason + "\"");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_stopReason = "fatal error: " + ex.Message;
|
|
_logger.Error("RunAsync main loop crashed", ex);
|
|
_logger.OpsError(OpsMarkers.Service, "CRASH reason=\"" + _stopReason + "\"");
|
|
}
|
|
finally
|
|
{
|
|
StopDevices();
|
|
try { Common.CHCNetSDK.NET_DVR_Cleanup(); } catch { /* ignore */ }
|
|
var ended = DateTime.Now;
|
|
var runtime = _serviceStartedAt == DateTime.MinValue ? TimeSpan.Zero : ended - _serviceStartedAt;
|
|
if (string.IsNullOrWhiteSpace(_stopReason))
|
|
_stopReason = "normal shutdown";
|
|
_logger.Ops(OpsMarkers.Service,
|
|
"STOP time=" + ended.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" runtime=" + FormatDuration(runtime) +
|
|
" reason=\"" + _stopReason + "\"");
|
|
_logger.WriteBizServiceStopped();
|
|
}
|
|
}
|
|
|
|
private static string ExtractMysqlErrorCode(string? err)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(err))
|
|
return "1042";
|
|
var m = System.Text.RegularExpressions.Regex.Match(err, @"\b(10\d{2})\b");
|
|
return m.Success ? m.Groups[1].Value : "1042";
|
|
}
|
|
|
|
private static string FormatDuration(TimeSpan ts)
|
|
{
|
|
if (ts.TotalHours >= 1)
|
|
return ((int)ts.TotalHours) + "h " + ts.Minutes + "m " + ts.Seconds + "s";
|
|
if (ts.TotalMinutes >= 1)
|
|
return ts.Minutes + "m " + ts.Seconds + "s";
|
|
return ts.Seconds + "s";
|
|
}
|
|
|
|
/// <summary>Fetches stored ACS events from the device for the given local time range and enqueues them into the same pipeline.</summary>
|
|
public Task<int> FetchAttendanceRecordsAsync(string deviceId, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.Run(() =>
|
|
{
|
|
DateTime? lastEventTimestamp;
|
|
bool dbInsertAllSucceeded;
|
|
int n = FetchAttendanceRecordsCore(deviceId, fromLocal, toLocal, cancellationToken, out lastEventTimestamp, out dbInsertAllSucceeded);
|
|
|
|
// CLI/manual fetches should also advance the last-sync cursor,
|
|
// otherwise incremental sync won't work until the scheduled loop runs.
|
|
var lastSyncStore = (_config.EnableDbIntegration && _attendanceMachineRepository != null)
|
|
? "db.attendance_machine.last_sync_date"
|
|
: "file." + GetLastSyncFilePath(deviceId);
|
|
_logger.Info("LastSync write check (CLI/manual fetch): device=" + deviceId + ", n=" + n +
|
|
", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)") +
|
|
", store=" + lastSyncStore);
|
|
|
|
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
|
|
{
|
|
WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value);
|
|
_logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId +
|
|
", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") +
|
|
". Meaning: next fetch will start after this timestamp.");
|
|
}
|
|
else if (n == 0 && dbInsertAllSucceeded)
|
|
{
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE] No punches found. Keeping last_sync_date unchanged. machine_id=" + deviceId);
|
|
}
|
|
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
|
|
{
|
|
_logger.Warn("LastSync NOT updated (CLI/manual fetch): DB insert did not fully succeed; will retry same window.");
|
|
_logger.JobWarn("attendance", "Cleanup skipped. Meaning: punches were not confirmed in DB, so device storage was NOT deleted.");
|
|
}
|
|
_logger.JobInfo("attendance", "Fetch result (manual/CLI): device=" + deviceId + " punchesFound=" + n +
|
|
". Meaning: number of attendance events pulled for the requested time window.");
|
|
return n;
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public bool OpenDoor(string deviceId, out string error)
|
|
{
|
|
return ControlDoor(deviceId, 1, out error);
|
|
}
|
|
|
|
public bool CloseDoor(string deviceId, out string error)
|
|
{
|
|
return ControlDoor(deviceId, 0, out error);
|
|
}
|
|
|
|
public bool StayOpen(string deviceId, out string error)
|
|
{
|
|
return ControlDoor(deviceId, 2, out error);
|
|
}
|
|
|
|
public bool StayClose(string deviceId, out string error)
|
|
{
|
|
return ControlDoor(deviceId, 3, out error);
|
|
}
|
|
|
|
public bool SetFaceTemplate(string deviceId, string cardNo, int readerNo, byte[] faceImageBytes, out string error)
|
|
{
|
|
error = "";
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
if (string.IsNullOrWhiteSpace(cardNo))
|
|
{
|
|
error = "cardNo is required";
|
|
return false;
|
|
}
|
|
if (faceImageBytes == null || faceImageBytes.Length == 0)
|
|
{
|
|
error = "faceImageBytes is empty";
|
|
return false;
|
|
}
|
|
if (faceImageBytes.Length > 200 * 1024)
|
|
{
|
|
error = "face image exceeds 200KB";
|
|
return false;
|
|
}
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
if (readerNo <= 0)
|
|
readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
|
|
|
|
int handle = -1;
|
|
IntPtr condPtr = IntPtr.Zero;
|
|
IntPtr inPtr = IntPtr.Zero;
|
|
IntPtr outPtr = IntPtr.Zero;
|
|
IntPtr facePtr = IntPtr.Zero;
|
|
|
|
try
|
|
{
|
|
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_COND();
|
|
cond.Init();
|
|
cond.dwSize = (uint)Marshal.SizeOf(cond);
|
|
cond.dwFaceNum = 1;
|
|
cond.dwEnableReaderNo = (uint)readerNo;
|
|
CopyUtf8(cardNo, cond.byCardNo);
|
|
|
|
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
|
|
Marshal.StructureToPtr(cond, condPtr, false);
|
|
|
|
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
|
|
session.UserId,
|
|
(uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FACE,
|
|
condPtr,
|
|
(int)cond.dwSize,
|
|
null,
|
|
IntPtr.Zero);
|
|
|
|
if (handle < 0)
|
|
{
|
|
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
|
|
error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FACE) failed, err=" + sdkErr;
|
|
_logger.Error(error);
|
|
return false;
|
|
}
|
|
|
|
var record = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_RECORD();
|
|
record.Init();
|
|
record.dwSize = (uint)Marshal.SizeOf(record);
|
|
CopyUtf8(cardNo, record.byCardNo);
|
|
record.dwFaceLen = (uint)faceImageBytes.Length;
|
|
facePtr = Marshal.AllocHGlobal(faceImageBytes.Length);
|
|
Marshal.Copy(faceImageBytes, 0, facePtr, faceImageBytes.Length);
|
|
record.pFaceBuffer = facePtr;
|
|
|
|
var status = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_STATUS();
|
|
status.Init();
|
|
status.dwSize = (uint)Marshal.SizeOf(status);
|
|
|
|
inPtr = Marshal.AllocHGlobal((int)record.dwSize);
|
|
outPtr = Marshal.AllocHGlobal((int)status.dwSize);
|
|
uint outLen = 0;
|
|
int attempts = 0;
|
|
bool accepted = false;
|
|
|
|
while (attempts++ < 300)
|
|
{
|
|
Marshal.StructureToPtr(record, inPtr, false);
|
|
Marshal.StructureToPtr(status, outPtr, false);
|
|
|
|
int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig(
|
|
handle,
|
|
inPtr,
|
|
(uint)record.dwSize,
|
|
outPtr,
|
|
(uint)status.dwSize,
|
|
ref outLen);
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT)
|
|
{
|
|
Thread.Sleep(50);
|
|
continue;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH)
|
|
{
|
|
if (accepted)
|
|
return true;
|
|
error = "face config finished before success status";
|
|
return false;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS)
|
|
{
|
|
status = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FACE_STATUS>(outPtr);
|
|
if (status.byRecvStatus == 1)
|
|
{
|
|
accepted = true;
|
|
continue;
|
|
}
|
|
|
|
string msg = DecodeCardNo(status.byErrorMsg);
|
|
error = "face template rejected, recvStatus=" + status.byRecvStatus + ", readerNo=" + status.dwReaderNo + ", msg=" + msg;
|
|
_logger.Warn(error);
|
|
return false;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED ||
|
|
rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION)
|
|
{
|
|
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
|
|
error = "face template remote config failed, rc=" + rc + ", err=" + sdkErr;
|
|
_logger.Error(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = "face template timed out waiting for device response";
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = "SetFaceTemplate exception: " + ex.Message;
|
|
_logger.Error(error, ex);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
|
|
try { if (facePtr != IntPtr.Zero) Marshal.FreeHGlobal(facePtr); } catch { /* ignore */ }
|
|
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
|
|
try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ }
|
|
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
public bool GetFaceTemplate(string deviceId, string cardNo, out byte[] faceImageBytes, out string error)
|
|
{
|
|
error = "";
|
|
faceImageBytes = Array.Empty<byte>();
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
|
|
bool ok = HikvisionTemplateInterop.GetFaceParam(session.UserId, channel, cardNo, out faceImageBytes);
|
|
if (!ok)
|
|
{
|
|
error = "GetFaceTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FACE_PARAM_CFG)");
|
|
_logger.Error(error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
public bool DeleteFaceTemplate(string deviceId, string cardNo, out string error)
|
|
{
|
|
error = "";
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
int channel = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
|
|
bool ok = HikvisionTemplateInterop.DeleteFaceParam(session.UserId, channel, cardNo);
|
|
if (!ok)
|
|
{
|
|
error = "DeleteFaceTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FACE_PARAM_CFG)");
|
|
_logger.Error(error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
public bool SetFingerprintTemplate(string deviceId, string cardNo, int readerNo, byte fingerId, byte[] fingerprintData, out string error)
|
|
{
|
|
error = "";
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
if (string.IsNullOrWhiteSpace(cardNo))
|
|
{
|
|
error = "cardNo is required";
|
|
return false;
|
|
}
|
|
if (fingerprintData == null || fingerprintData.Length == 0)
|
|
{
|
|
error = "fingerprintData is empty";
|
|
return false;
|
|
}
|
|
if (fingerprintData.Length > EventByDeploy.CHCNetSDK.MAX_FINGER_PRINT_LEN)
|
|
{
|
|
error = "fingerprintData exceeds MAX_FINGER_PRINT_LEN";
|
|
return false;
|
|
}
|
|
if (fingerId == 0 || fingerId > 10)
|
|
{
|
|
error = "fingerId must be 1..10";
|
|
return false;
|
|
}
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
if (readerNo <= 0)
|
|
readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
|
|
|
|
int handle = -1;
|
|
IntPtr condPtr = IntPtr.Zero;
|
|
IntPtr inPtr = IntPtr.Zero;
|
|
IntPtr outPtr = IntPtr.Zero;
|
|
try
|
|
{
|
|
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_COND();
|
|
cond.Init();
|
|
cond.dwSize = (uint)Marshal.SizeOf(cond);
|
|
cond.dwFingerPrintNum = 1;
|
|
cond.dwEnableReaderNo = (uint)readerNo;
|
|
cond.byFingerPrintID = fingerId;
|
|
CopyUtf8(cardNo, cond.byCardNo);
|
|
|
|
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
|
|
Marshal.StructureToPtr(cond, condPtr, false);
|
|
|
|
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
|
|
session.UserId,
|
|
(uint)EventByDeploy.CHCNetSDK.NET_DVR_SET_FINGERPRINT,
|
|
condPtr,
|
|
(int)cond.dwSize,
|
|
null,
|
|
IntPtr.Zero);
|
|
|
|
if (handle < 0)
|
|
{
|
|
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
|
|
error = "NET_DVR_StartRemoteConfig(NET_DVR_SET_FINGERPRINT) failed, err=" + sdkErr;
|
|
_logger.Error(error);
|
|
return false;
|
|
}
|
|
|
|
var record = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_RECORD();
|
|
record.Init();
|
|
record.dwSize = (uint)Marshal.SizeOf(record);
|
|
CopyUtf8(cardNo, record.byCardNo);
|
|
record.dwEnableReaderNo = (uint)readerNo;
|
|
record.byFingerPrintID = fingerId;
|
|
record.byFingerType = 0;
|
|
record.dwFingerPrintLen = (uint)fingerprintData.Length;
|
|
Buffer.BlockCopy(fingerprintData, 0, record.byFingerData, 0, fingerprintData.Length);
|
|
|
|
var status = new EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_STATUS();
|
|
status.Init();
|
|
status.dwSize = (uint)Marshal.SizeOf(status);
|
|
|
|
inPtr = Marshal.AllocHGlobal((int)record.dwSize);
|
|
outPtr = Marshal.AllocHGlobal((int)status.dwSize);
|
|
uint outLen = 0;
|
|
int attempts = 0;
|
|
bool accepted = false;
|
|
|
|
while (attempts++ < 300)
|
|
{
|
|
Marshal.StructureToPtr(record, inPtr, false);
|
|
Marshal.StructureToPtr(status, outPtr, false);
|
|
|
|
int rc = EventByDeploy.CHCNetSDK.NET_DVR_SendWithRecvRemoteConfig(
|
|
handle,
|
|
inPtr,
|
|
(uint)record.dwSize,
|
|
outPtr,
|
|
(uint)status.dwSize,
|
|
ref outLen);
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_NEEDWAIT)
|
|
{
|
|
Thread.Sleep(50);
|
|
continue;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FINISH)
|
|
{
|
|
if (accepted)
|
|
return true;
|
|
error = "fingerprint config finished before success status";
|
|
return false;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_SUCCESS)
|
|
{
|
|
status = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FINGERPRINT_STATUS>(outPtr);
|
|
if (status.byRecvStatus == 0)
|
|
{
|
|
accepted = true;
|
|
continue;
|
|
}
|
|
|
|
string msg = DecodeCardNo(status.byErrorMsg);
|
|
error = "fingerprint template rejected, recvStatus=" + status.byRecvStatus +
|
|
", readerRecvStatus=" + status.byCardReaderRecvStatus +
|
|
", cardReaderNo=" + status.dwCardReaderNo +
|
|
", msg=" + msg;
|
|
_logger.Warn(error);
|
|
return false;
|
|
}
|
|
|
|
if (rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_FAILED ||
|
|
rc == (int)EventByDeploy.CHCNetSDK.NET_SDK_SENDWITHRECV_STATUS.NET_SDK_CONFIG_STATUS_EXCEPTION)
|
|
{
|
|
var sdkErr = EventByDeploy.CHCNetSDK.NET_DVR_GetLastError();
|
|
error = "fingerprint template remote config failed, rc=" + rc + ", err=" + sdkErr;
|
|
_logger.Error(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = "fingerprint template timed out waiting for device response";
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = "SetFingerprintTemplate exception: " + ex.Message;
|
|
_logger.Error(error, ex);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
|
|
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
|
|
try { if (inPtr != IntPtr.Zero) Marshal.FreeHGlobal(inPtr); } catch { /* ignore */ }
|
|
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
public bool GetFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out byte[] fingerprintData, out string error)
|
|
{
|
|
error = "";
|
|
fingerprintData = Array.Empty<byte>();
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
|
|
bool ok = HikvisionTemplateInterop.GetFingerprintParam(session.UserId, channel, cardNo, fingerId, out fingerprintData);
|
|
if (!ok)
|
|
{
|
|
error = "GetFingerprintTemplate failed, " + BuildSdkError("NET_DVR_GetDeviceConfig(NET_DVR_FINGERPRINT_PARAM)");
|
|
_logger.Error(error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports face + fingerprint templates for one SDK user key (<paramref name="cardNo"/> is written to device as card / employee string).
|
|
/// Uses <c>NET_DVR_GetDeviceConfig</c> with <c>NET_DVR_GET_FACE_PARAM_CFG</c> and <c>NET_DVR_GET_FINGERPRINT_PARAM</c> (per-finger 1..10).
|
|
/// </summary>
|
|
private bool TryBuildUserTemplatesPayload(string deviceId, string cardNo, out UserTemplateExportPayload payload, out string error)
|
|
{
|
|
payload = new UserTemplateExportPayload();
|
|
error = "";
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(cardNo))
|
|
{
|
|
error = "cardNo is required (Hikvision enroll key; often the same string as employeeNo)";
|
|
return false;
|
|
}
|
|
|
|
if (FindSession(deviceId) == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
payload = new UserTemplateExportPayload
|
|
{
|
|
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
|
deviceId = deviceId.Trim(),
|
|
cardNo = cardNo.Trim()
|
|
};
|
|
|
|
var faceItem = new FaceTemplateExportItem { attempted = true };
|
|
if (GetFaceTemplate(deviceId, cardNo, out var faceBytes, out var faceErr))
|
|
{
|
|
if (faceBytes != null && faceBytes.Length > 0)
|
|
{
|
|
faceItem.present = true;
|
|
faceItem.byteLength = faceBytes.Length;
|
|
faceItem.dataBase64 = Convert.ToBase64String(faceBytes);
|
|
faceItem.error = "";
|
|
}
|
|
else
|
|
{
|
|
faceItem.present = false;
|
|
faceItem.error = string.IsNullOrEmpty(faceErr) ? "no face template on device (empty response)" : faceErr;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
faceItem.present = false;
|
|
faceItem.error = string.IsNullOrEmpty(faceErr) ? "face query failed" : faceErr;
|
|
}
|
|
|
|
payload.face = faceItem;
|
|
|
|
for (byte fingerId = 1; fingerId <= 10; fingerId++)
|
|
{
|
|
var fpItem = new FingerprintTemplateExportItem { fingerId = fingerId, attempted = true };
|
|
if (GetFingerprintTemplate(deviceId, cardNo, fingerId, out var fpData, out var fpErr))
|
|
{
|
|
if (fpData != null && fpData.Length > 0)
|
|
{
|
|
fpItem.present = true;
|
|
fpItem.byteLength = fpData.Length;
|
|
fpItem.dataBase64 = Convert.ToBase64String(fpData);
|
|
fpItem.error = "";
|
|
}
|
|
else
|
|
{
|
|
fpItem.present = false;
|
|
fpItem.error = string.IsNullOrEmpty(fpErr) ? "slot empty" : fpErr;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
fpItem.present = false;
|
|
fpItem.error = string.IsNullOrEmpty(fpErr) ? "fingerprint query failed" : fpErr;
|
|
}
|
|
|
|
payload.fingerprints.Add(fpItem);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool TryExportUserTemplatesToFile(string deviceId, string cardNo, string? outputPath, out string writtenPath, out string error)
|
|
{
|
|
writtenPath = "";
|
|
error = "";
|
|
try
|
|
{
|
|
if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var buildErr))
|
|
{
|
|
error = buildErr;
|
|
return false;
|
|
}
|
|
|
|
var path = string.IsNullOrWhiteSpace(outputPath)
|
|
? Path.Combine(_config.LogDirectory, BuildDefaultTemplateExportFileName(deviceId, cardNo))
|
|
: outputPath.Trim();
|
|
|
|
var full = Path.GetFullPath(path);
|
|
var dir = Path.GetDirectoryName(full);
|
|
if (!string.IsNullOrEmpty(dir))
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(payload);
|
|
File.WriteAllText(full, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
writtenPath = full;
|
|
_logger.Info("User template export completed: path=" + full + " device=" + deviceId + " cardNo=" + cardNo);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
_logger.Error("TryExportUserTemplatesToFile failed", ex);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool TryExportAllUsersTemplatesToFile(
|
|
string deviceId,
|
|
string? outDir,
|
|
int pageSize,
|
|
int maxUsers,
|
|
CancellationToken cancellationToken,
|
|
out string writtenJsonPath,
|
|
out string error)
|
|
{
|
|
writtenJsonPath = "";
|
|
error = "";
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
if (pageSize <= 0) pageSize = 30;
|
|
if (maxUsers <= 0) maxUsers = 10000;
|
|
|
|
var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim();
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var jsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId));
|
|
writtenJsonPath = jsonPath;
|
|
|
|
var faceLogPath = Path.Combine(dir, "face_templates_log.txt");
|
|
var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt");
|
|
var templateJobLogsDir = Path.Combine(_config.LogDirectory, "logs", "template_fetching_logs");
|
|
Directory.CreateDirectory(templateJobLogsDir);
|
|
var templateFaceStatusLogPath = Path.Combine(templateJobLogsDir, "face_templates_fetch_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt");
|
|
var templateFingerStatusLogPath = Path.Combine(templateJobLogsDir, "finger_templates_fetch_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt");
|
|
|
|
var root = new AllUsersTemplateExportPayload
|
|
{
|
|
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
|
deviceId = deviceId.Trim(),
|
|
userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search"
|
|
};
|
|
|
|
var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken);
|
|
if (!string.IsNullOrEmpty(listErr))
|
|
root.listFetchError = listErr;
|
|
|
|
_logger.Info("ExportAllTemplates: device=" + deviceId + ", discoveredUsers=" + cardNos.Count +
|
|
", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr));
|
|
|
|
using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export face templates");
|
|
fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " Export fingerprint templates");
|
|
|
|
foreach (var cardNo in cardNos)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
if (!TryBuildUserTemplatesPayload(deviceId, cardNo, out var payload, out var perErr))
|
|
{
|
|
// Keep going even if one card payload fails unexpectedly.
|
|
payload = new UserTemplateExportPayload
|
|
{
|
|
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
|
deviceId = deviceId.Trim(),
|
|
cardNo = cardNo
|
|
};
|
|
payload.face = new FaceTemplateExportItem { attempted = true, present = false, byteLength = 0, error = perErr };
|
|
payload.fingerprints = new List<FingerprintTemplateExportItem>();
|
|
}
|
|
|
|
root.users.Add(payload);
|
|
|
|
// Face log line (one per user)
|
|
var f = payload.face;
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") +
|
|
" device=" + deviceId +
|
|
" cardNo=" + cardNo +
|
|
" present=" + f.present +
|
|
" len=" + f.byteLength +
|
|
" error=" + (string.IsNullOrEmpty(f.error) ? "-" : f.error));
|
|
|
|
// Fingerprint log lines (one per finger slot)
|
|
foreach (var fp in payload.fingerprints)
|
|
{
|
|
fpSw.WriteLine(DateTime.UtcNow.ToString("o") +
|
|
" device=" + deviceId +
|
|
" cardNo=" + cardNo +
|
|
" fingerId=" + fp.fingerId +
|
|
" present=" + fp.present +
|
|
" len=" + fp.byteLength +
|
|
" error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error));
|
|
}
|
|
}
|
|
|
|
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root);
|
|
File.WriteAllText(jsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
_logger.Info("ExportAllTemplates: wrote json path=" + jsonPath + ", users=" + root.users.Count);
|
|
|
|
return true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
error = "cancelled";
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
_logger.Error("TryExportAllUsersTemplatesToFile failed", ex);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string BuildAllUsersTemplatesExportFileName(string deviceId)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
if (string.IsNullOrEmpty(key))
|
|
key = (deviceId ?? "").Trim();
|
|
foreach (var ch in Path.GetInvalidFileNameChars())
|
|
key = key.Replace(ch, '_');
|
|
return "all_user_templates_" + key + "_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json";
|
|
}
|
|
|
|
private List<string> FetchAllUserCardNosStdXml(
|
|
int sessionUserId,
|
|
int pageSize,
|
|
int maxUsers,
|
|
out string listError,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
listError = "";
|
|
var all = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Best-effort pagination. If response doesn't include InfoList, we try alternative wrapper keys.
|
|
string searchId = "1";
|
|
int offset = 0;
|
|
const int maxPages = 2000; // guardrails
|
|
|
|
for (int page = 0; page < maxPages && all.Count < maxUsers; page++)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
_logger.Info("ExportAllTemplates: user-list page=" + page + ", offset=" + offset + ", maxUsers=" + maxUsers);
|
|
|
|
// Try wrapper key #1
|
|
string body1 = BuildJsonUserInfoSearchCond("UserInfoSearchCond", searchId, offset, pageSize);
|
|
string raw1 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var sdkErr1);
|
|
var pageNos = ExtractUserCardNosFromStdXml(raw1);
|
|
|
|
if (pageNos.Count == 0)
|
|
{
|
|
// Try wrapper key #2
|
|
string body2 = BuildJsonUserInfoSearchCond("AcsUserInfoCond", searchId, offset, pageSize);
|
|
string raw2 = StdXmlCall(sessionUserId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var sdkErr2);
|
|
pageNos = ExtractUserCardNosFromStdXml(raw2);
|
|
|
|
if (pageNos.Count == 0)
|
|
{
|
|
listError = "user-list fetch returned no employee/card keys. sdkErr1=" + sdkErr1 + ", sdkErr2=" + sdkErr2;
|
|
var snippet1 = raw1.Length > 800 ? raw1.Substring(0, 800) : raw1;
|
|
var snippet2 = raw2.Length > 800 ? raw2.Substring(0, 800) : raw2;
|
|
_logger.Warn("ExportAllTemplates: user-list parse empty; stopping. offset=" + offset + ", listError=" + listError +
|
|
", raw1_snip=\"" + snippet1.Replace("\n", " ").Replace("\r", " ") + "\"" +
|
|
", raw2_snip=\"" + snippet2.Replace("\n", " ").Replace("\r", " ") + "\"");
|
|
break;
|
|
}
|
|
}
|
|
|
|
foreach (var c in pageNos)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(c))
|
|
continue;
|
|
all.Add(c.Trim());
|
|
if (all.Count >= maxUsers)
|
|
break;
|
|
}
|
|
|
|
if (pageNos.Count < pageSize)
|
|
break; // probably end
|
|
|
|
offset += pageSize;
|
|
}
|
|
|
|
return all.ToList();
|
|
}
|
|
|
|
private static string BuildJsonUserInfoSearchCond(string wrapperKey, string searchId, int startOffset, int maxResults)
|
|
{
|
|
return "{ \"" + wrapperKey + "\": { " +
|
|
"\"searchID\": \"" + EscapeJsonStatic(searchId) + "\"," +
|
|
"\"searchResultPosition\": " + startOffset + "," +
|
|
"\"maxResults\": " + maxResults +
|
|
" } }";
|
|
}
|
|
|
|
private static string EscapeJsonStatic(string value)
|
|
{
|
|
if (value == null) return "";
|
|
return value
|
|
.Replace("\\", "\\\\")
|
|
.Replace("\"", "\\\"")
|
|
.Replace("\r", "\\r")
|
|
.Replace("\n", "\\n")
|
|
.Replace("\t", "\\t");
|
|
}
|
|
|
|
private static List<string> ExtractUserCardNosFromStdXml(string responseJson)
|
|
{
|
|
var result = new List<string>();
|
|
if (string.IsNullOrWhiteSpace(responseJson))
|
|
return result;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(responseJson);
|
|
if (root == null)
|
|
return result;
|
|
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var keySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"employeeNo",
|
|
"employeeNoString",
|
|
"cardNo",
|
|
"cardNoString",
|
|
"employeeId",
|
|
"userId"
|
|
};
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (keySet.Contains(kv.Key) && kv.Value != null)
|
|
{
|
|
string? s = kv.Value is string ss ? ss : kv.Value.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s))
|
|
{
|
|
s = s.Trim();
|
|
// Avoid grabbing non-IDs (heuristic: require at least 1 digit and max len)
|
|
if (s.Length <= 64 && s.Any(char.IsDigit))
|
|
{
|
|
if (seen.Add(s))
|
|
result.Add(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (kv.Value != null)
|
|
stack.Push(kv.Value);
|
|
}
|
|
}
|
|
else if (cur is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort only.
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string BuildDefaultTemplateExportFileName(string deviceId, string cardNo)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
if (string.IsNullOrEmpty(key))
|
|
key = (deviceId ?? "").Trim();
|
|
foreach (var ch in Path.GetInvalidFileNameChars())
|
|
key = key.Replace(ch, '_');
|
|
var safeCard = cardNo ?? "";
|
|
foreach (var ch in Path.GetInvalidFileNameChars())
|
|
safeCard = safeCard.Replace(ch, '_');
|
|
return "user_templates_" + key + "_" + safeCard + "_" +
|
|
DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".json";
|
|
}
|
|
|
|
public bool DeleteFingerprintTemplate(string deviceId, string cardNo, byte fingerId, out string error)
|
|
{
|
|
error = "";
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
int channel = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
|
|
bool ok = HikvisionTemplateInterop.DeleteFingerprintParam(session.UserId, channel, cardNo, fingerId);
|
|
if (!ok)
|
|
{
|
|
error = "DeleteFingerprintTemplate failed, " + BuildSdkError("NET_DVR_SetDeviceConfig(NET_DVR_DEL_FINGERPRINT_PARAM)");
|
|
_logger.Error(error);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
public bool SyncTemplatesToDevice(
|
|
string deviceId,
|
|
string cardNo,
|
|
int faceReaderNo,
|
|
byte[] faceImageBytes,
|
|
int fingerprintReaderNo,
|
|
byte fingerId,
|
|
byte[] fingerprintData,
|
|
out string error)
|
|
{
|
|
error = "";
|
|
if (faceImageBytes != null && faceImageBytes.Length > 0)
|
|
{
|
|
if (!SetFaceTemplate(deviceId, cardNo, faceReaderNo, faceImageBytes, out error))
|
|
return false;
|
|
}
|
|
|
|
if (fingerprintData != null && fingerprintData.Length > 0)
|
|
{
|
|
if (!SetFingerprintTemplate(deviceId, cardNo, fingerprintReaderNo, fingerId, fingerprintData, out error))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void StartDevices()
|
|
{
|
|
// Callback MUST use the same native module as NET_DVR_Init (Common). EventByDeploy uses a different DllImport path → second copy → err=3 NET_DVR_NOINIT.
|
|
if (_callbackDelegate == null)
|
|
{
|
|
_logger.Info("NET_DVR_SetDVRMessageCallBack_V50: registering via Common.CHCNetSDK (before per-device login).");
|
|
_callbackDelegate = new Common.CHCNetSDK.MSGCallBack(AlarmCallback);
|
|
bool cbOk = Common.CHCNetSDK.NET_DVR_SetDVRMessageCallBack_V50(0, _callbackDelegate, IntPtr.Zero);
|
|
if (!cbOk)
|
|
{
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
LogSdkFailure("NET_DVR_SetDVRMessageCallBack_V50", err, null, null,
|
|
"Failed to register alarm callback delegate (Common SDK module).", isWarning: false);
|
|
}
|
|
else
|
|
{
|
|
_logger.Info("NET_DVR_SetDVRMessageCallBack_V50 succeeded (Common), index=0, delegate=MSGCallBack. build=" + BuildMarker);
|
|
}
|
|
}
|
|
|
|
var devicesToLogin = ResolveRuntimeDevices(emitBizStartupSummary: true);
|
|
|
|
if (devicesToLogin == null)
|
|
{
|
|
_logger.OpsWarn(OpsMarkers.Connectivity, "No device list (null); skipping login.");
|
|
return;
|
|
}
|
|
|
|
if (devicesToLogin.Count == 0)
|
|
{
|
|
_logger.OpsWarn(OpsMarkers.Connectivity, "No devices selected; skipping login.");
|
|
return;
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Connectivity, "Login attempts starting for " + devicesToLogin.Count + " device(s).");
|
|
|
|
int onlineCount = 0;
|
|
int offlineCount = 0;
|
|
foreach (var device in devicesToLogin)
|
|
{
|
|
var machineName = device.Model ?? "";
|
|
var probe = ConnectivityDiagnostics.ProbeBeforeLogin(device.Ip, device.Port, tcpTimeoutMs: 3000, tryPing: true);
|
|
|
|
// Hard-fail before SDK only for invalid IP. Ping/TCP failures are still attempted via SDK
|
|
// (some networks block ICMP) but are recorded with accurate stage labels.
|
|
if (probe.FailureStage == ConnectivityFailureStage.InvalidIp)
|
|
{
|
|
offlineCount++;
|
|
_logger.Totals.MachinesProcessed++;
|
|
_logger.Totals.MachinesFailed++;
|
|
_logger.OpsWarn(OpsMarkers.Connectivity,
|
|
ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, probe, loginOk: false));
|
|
_unreachableDevices.ReportFailure(device.DeviceId, machineName, device.Ip ?? "", device.Port,
|
|
probe.FailureStage, probe.FriendlyReason);
|
|
TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null);
|
|
continue;
|
|
}
|
|
|
|
if (probe.TcpChecked && !probe.TcpOk)
|
|
{
|
|
// Still try SDK login (device may accept after intermittent block), but label TCP failure if login fails.
|
|
_logger.Diag("attendance", "Pre-login TCP closed for " + device.Ip + ":" + device.Port + " — attempting SDK login anyway.");
|
|
}
|
|
|
|
_logger.Totals.MachinesProcessed++;
|
|
_logger.Diag("attendance", "NET_DVR_Login_V30 connecting " + device.Ip + ":" + device.Port + " device=" + 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)
|
|
{
|
|
offlineCount++;
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
var classified = ConnectivityDiagnostics.ClassifySdkLoginFailure(err, probe);
|
|
// Prefer earlier TCP/ping stage when SDK only says "connection failed".
|
|
if (err == 7 && probe.TcpChecked && !probe.TcpOk)
|
|
{
|
|
classified.FailureStage = ConnectivityFailureStage.TcpPortClosed;
|
|
classified.FriendlyReason = probe.FriendlyReason;
|
|
}
|
|
else if (err == 7 && probe.PingChecked && !probe.PingOk && probe.TcpOk)
|
|
{
|
|
// Ping failed but TCP was open — keep SDK classification (connection failed).
|
|
}
|
|
else if (probe.PingChecked && !probe.PingOk && !probe.TcpOk)
|
|
{
|
|
classified.FailureStage = ConnectivityFailureStage.PingFailed;
|
|
classified.FriendlyReason = probe.FriendlyReason;
|
|
}
|
|
|
|
_logger.OpsWarn(OpsMarkers.Connectivity,
|
|
ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, classified, loginOk: false));
|
|
_unreachableDevices.ReportFailure(device.DeviceId, machineName, device.Ip ?? "", device.Port,
|
|
classified.FailureStage, classified.FriendlyReason);
|
|
_logger.Totals.MachinesFailed++;
|
|
_logger.Diag("attendance", "Login failed device=" + device.DeviceId + " sdkErr=" + err + " " + TranslateSdkErrorCode(err));
|
|
TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null);
|
|
continue;
|
|
}
|
|
|
|
onlineCount++;
|
|
_logger.Totals.MachinesConnected++;
|
|
_unreachableDevices.ReportRecovered(device.DeviceId, machineName, device.Ip ?? "", device.Port);
|
|
_logger.Ops(OpsMarkers.Connectivity,
|
|
ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, probe, loginOk: true));
|
|
|
|
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,
|
|
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));
|
|
alarmHandle = Common.CHCNetSDK.NET_DVR_SetupAlarmChan_V50(userId, ref alarmParam, IntPtr.Zero, 0);
|
|
if (alarmHandle < 0)
|
|
{
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
_logger.Diag("attendance", "Alarm setup failed device=" + device.DeviceId + " err=" + err + " (session kept for fetch/templates).");
|
|
}
|
|
|
|
_sessions.Add(new DeviceSession(device, userId, alarmHandle));
|
|
TryUpdateMachineRuntimeState(device.Ip, "IDLE", null, null);
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Connectivity, "SUMMARY selected=" + devicesToLogin.Count +
|
|
" reachable=" + onlineCount + " unreachable=" + offlineCount + " sessions=" + _sessions.Count);
|
|
}
|
|
|
|
private void StopDevices()
|
|
{
|
|
foreach (var s in _sessions.ToArray())
|
|
{
|
|
try
|
|
{
|
|
if (s.AlarmHandle >= 0)
|
|
{
|
|
if (!Common.CHCNetSDK.NET_DVR_CloseAlarmChan_V30(s.AlarmHandle))
|
|
{
|
|
LogSdkFailure("NET_DVR_CloseAlarmChan_V30", Common.CHCNetSDK.NET_DVR_GetLastError(),
|
|
s.Device.DeviceId, s.Device.Ip, "Alarm channel close failed during shutdown.", isWarning: true);
|
|
}
|
|
else
|
|
{
|
|
_logger.Info("NET_DVR_CloseAlarmChan_V30 succeeded for " + s.Device.DeviceId + ".");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("Close alarm channel failed for " + s.Device.DeviceId, ex);
|
|
}
|
|
|
|
try
|
|
{
|
|
if (s.UserId >= 0)
|
|
{
|
|
Common.CHCNetSDK.NET_DVR_Logout_V30(s.UserId);
|
|
}
|
|
// Status update must NOT touch last_sync_date (cursor).
|
|
TryUpdateMachineRuntimeState(s.Device.Ip, "NOT CONNECTED", null, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("Logout failed for " + s.Device.DeviceId, ex);
|
|
}
|
|
}
|
|
|
|
_sessions.Clear();
|
|
}
|
|
|
|
private async Task HistoricalSchedulerLoop(CancellationToken token)
|
|
{
|
|
var intervalMinutes = _config.AttendanceSyncIntervalMinutes > 0
|
|
? _config.AttendanceSyncIntervalMinutes
|
|
: _config.HistoricalFetchIntervalMinutes;
|
|
if (intervalMinutes <= 0)
|
|
intervalMinutes = 5;
|
|
|
|
bool firstRun = true;
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
bool isImmediateStartupRun = firstRun;
|
|
if (!firstRun)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
firstRun = false;
|
|
|
|
ExecuteAttendanceFetchCycle(token, isImmediateStartupRun);
|
|
}
|
|
}
|
|
|
|
private void ExecuteAttendanceFetchCycle(CancellationToken token, bool isImmediateStartupRun)
|
|
{
|
|
var cycleStarted = DateTime.Now;
|
|
int successDevices = 0, failedDevices = 0, skippedDevices = 0;
|
|
_logger.Ops(OpsMarkers.Attendance, "JOB CYCLE START" + (isImmediateStartupRun ? " (startup)" : ""));
|
|
|
|
// Do not re-emit scope/startup business lines on every cycle.
|
|
var runtimeDevices = ResolveRuntimeDevices(emitBizStartupSummary: false)
|
|
?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
var sessionsSnapshot = _sessions.ToArray();
|
|
|
|
if (runtimeDevices.Count == 0)
|
|
{
|
|
_logger.OpsWarn(OpsMarkers.Attendance, "SKIPPED — no runtime devices");
|
|
_logger.Ops(OpsMarkers.Attendance, "JOB CYCLE END success=0 skipped=0 failed=0 duration=" +
|
|
FormatDuration(DateTime.Now - cycleStarted));
|
|
return;
|
|
}
|
|
|
|
var runtimeKeys = new HashSet<string>(
|
|
runtimeDevices.Select(d => DeviceIdentity.CanonicalLookupKey(d.DeviceId)).Where(x => !string.IsNullOrWhiteSpace(x)),
|
|
StringComparer.Ordinal);
|
|
var runtimeIps = new HashSet<string>(
|
|
runtimeDevices.Select(d => (d.Ip ?? "").Trim()).Where(x => x.Length > 0),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var to = DateTime.Now;
|
|
var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes);
|
|
if (_config.HistoricalFetchLookbackMinutes <= 0)
|
|
fallbackFrom = to.AddDays(-1);
|
|
|
|
foreach (var d in runtimeDevices)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(d.DeviceId);
|
|
var ip = (d.Ip ?? "").Trim();
|
|
var session = sessionsSnapshot.FirstOrDefault(s =>
|
|
(key.Length > 0 && DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key) ||
|
|
(ip.Length > 0 && string.Equals((s.Device.Ip ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
WriteBizAttendanceConnecting(d.DeviceId ?? "", ip, d.Port);
|
|
|
|
if (session == null)
|
|
{
|
|
skippedDevices++;
|
|
failedDevices++;
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Status : Connection failed",
|
|
"",
|
|
"Reason : Device is offline.",
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
_logger.OpsWarn(OpsMarkers.Attendance, "device=" + d.DeviceId + " ip=" + ip +
|
|
" SKIPPED reason=\"target offline (no SDK session)\"");
|
|
_unreachableDevices.ReportFailure(d.DeviceId, d.Model ?? "", ip, d.Port,
|
|
ConnectivityFailureStage.Unknown, "Device is offline.");
|
|
continue;
|
|
}
|
|
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Status : Connected successfully",
|
|
"",
|
|
"Fetching attendance...",
|
|
"");
|
|
|
|
var stats = new AttendanceDeviceCycleStats
|
|
{
|
|
DeviceId = session.Device.DeviceId ?? "",
|
|
DeviceIp = session.Device.Ip ?? ""
|
|
};
|
|
|
|
try
|
|
{
|
|
var sessionKey = DeviceIdentity.CanonicalLookupKey(session.Device.DeviceId);
|
|
var sessionIp = (session.Device.Ip ?? "").Trim();
|
|
bool eligibleById = sessionKey.Length > 0 && runtimeKeys.Contains(sessionKey);
|
|
bool eligibleByIp = sessionIp.Length > 0 && runtimeIps.Contains(sessionIp);
|
|
if ((runtimeKeys.Count > 0 || runtimeIps.Count > 0) && !eligibleById && !eligibleByIp)
|
|
{
|
|
skippedDevices++;
|
|
_logger.Biz(BizChannel.Attendance, "No attendance records found.", "");
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
continue;
|
|
}
|
|
|
|
var lastSync = ReadLastSyncTimestamp(session.Device.DeviceId, out var lastSyncReason);
|
|
var serverNow = DateTime.Now;
|
|
var from = lastSync.HasValue ? lastSync.Value.AddSeconds(1) : fallbackFrom;
|
|
|
|
if (from > serverNow)
|
|
{
|
|
var resetFrom = serverNow.AddMinutes(-10);
|
|
_logger.OpsWarn(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_WINDOW] Invalid future last_sync_date detected" +
|
|
" machine_id=" + (session.Device.DeviceId ?? "") +
|
|
" machine_ip=" + (session.Device.Ip ?? "") +
|
|
" last_sync_date=" + (lastSync.HasValue
|
|
? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
|
: "(null)") +
|
|
" current_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" reset_from=" + resetFrom.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" reason=\"" + lastSyncReason + "\"");
|
|
from = resetFrom;
|
|
}
|
|
|
|
if ((serverNow - from).TotalHours > 24)
|
|
{
|
|
var resetFrom = serverNow.AddHours(-24);
|
|
_logger.OpsWarn(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_WINDOW] Fetch range exceeded maximum limit. Resetting window." +
|
|
" machine_id=" + (session.Device.DeviceId ?? "") +
|
|
" machine_ip=" + (session.Device.Ip ?? "") +
|
|
" from_time=" + from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" to_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" reset_from=" + resetFrom.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
|
from = resetFrom;
|
|
}
|
|
|
|
if (from > serverNow)
|
|
from = serverNow.AddMinutes(-1);
|
|
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_WINDOW]" +
|
|
" machine_id=" + (session.Device.DeviceId ?? "") +
|
|
" machine_ip=" + (session.Device.Ip ?? "") +
|
|
" last_sync_date=" + (lastSync.HasValue
|
|
? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
|
: "(null)") +
|
|
" from_time=" + from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" to_time=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" server_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
|
|
|
DateTime? lastEventTimestamp;
|
|
bool dbInsertAllSucceeded;
|
|
int n = FetchAttendanceRecordsCore(session.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded, stats);
|
|
stats.EventsReceived = Math.Max(stats.EventsReceived, n + stats.SystemEventsSkipped + stats.Duplicates);
|
|
|
|
if (stats.DbInserted > 0)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Successfully inserted " + stats.DbInserted + " attendance records.",
|
|
"");
|
|
foreach (var punch in stats.InsertedPunches)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Employee " + punch.EmpNo + " -> " + BizFriendlyReasons.FormatTime(punch.Time));
|
|
}
|
|
_logger.BizBlank(BizChannel.Attendance);
|
|
}
|
|
else if (stats.Failed > 0)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Attendance insert failed.",
|
|
"",
|
|
"Reason : One or more records could not be saved to the database.",
|
|
"");
|
|
}
|
|
else
|
|
{
|
|
_logger.Biz(BizChannel.Attendance, "No attendance records found.", "");
|
|
}
|
|
|
|
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
|
|
{
|
|
WriteLastSyncTimestamp(session.Device.DeviceId, lastEventTimestamp.Value);
|
|
stats.LastSyncUpdated = true;
|
|
if (_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence)
|
|
{
|
|
if (TryCleanupDeviceAttendanceStorage(session, lastEventTimestamp.Value, out var cleanupErr))
|
|
stats.CleanupResult = "OK";
|
|
else
|
|
{
|
|
stats.CleanupResult = "FAILED: " + cleanupErr;
|
|
_logger.Diag("attendance", "cleanup failed device=" + session.Device.DeviceId + " err=" + cleanupErr);
|
|
}
|
|
}
|
|
else
|
|
stats.CleanupResult = "SKIPPED (DB persistence off)";
|
|
successDevices++;
|
|
}
|
|
else if (n == 0 && dbInsertAllSucceeded)
|
|
{
|
|
// Do not advance last_sync_date on empty windows — keeps the cursor safe for retries.
|
|
stats.LastSyncUpdated = false;
|
|
stats.CleanupResult = "SKIPPED (no punches)";
|
|
successDevices++;
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE] No punches found. Keeping last_sync_date unchanged." +
|
|
" machine_id=" + (session.Device.DeviceId ?? "") +
|
|
" machine_ip=" + (session.Device.Ip ?? ""));
|
|
}
|
|
else
|
|
{
|
|
stats.LastSyncUpdated = false;
|
|
stats.CleanupResult = "SKIPPED (DB insert failed — safety lock)";
|
|
failedDevices++;
|
|
_logger.OpsWarn(OpsMarkers.Attendance, "device=" + stats.DeviceId +
|
|
" last_sync=NOT_UPDATED cleanup=SKIPPED reason=\"DB insert failed\"");
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"device=" + stats.DeviceId + " ip=" + stats.DeviceIp +
|
|
" eventsReceived=" + stats.EventsReceived +
|
|
" validPunches=" + stats.ValidPunches +
|
|
" systemSkipped=" + stats.SystemEventsSkipped +
|
|
" dbInserted=" + stats.DbInserted +
|
|
" duplicates=" + stats.Duplicates +
|
|
" failed=" + stats.Failed +
|
|
" last_sync=" + (stats.LastSyncUpdated ? "UPDATED" : "NOT_UPDATED") +
|
|
" cleanup=" + stats.CleanupResult);
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failedDevices++;
|
|
_logger.Error("Scheduled historical fetch failed for " + session.Device.DeviceId, ex);
|
|
_logger.OpsError(OpsMarkers.Attendance, "device=" + session.Device.DeviceId + " FAILED reason=\"" + ex.Message + "\"");
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"Attendance fetch failed.",
|
|
"",
|
|
"Reason : " + ex.Message,
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Attendance);
|
|
}
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Attendance, "JOB CYCLE END success=" + successDevices +
|
|
" skipped=" + skippedDevices + " failed=" + failedDevices +
|
|
" duration=" + FormatDuration(DateTime.Now - cycleStarted));
|
|
}
|
|
|
|
private void WriteBizAttendanceConnecting(string machineId, string ip, int port)
|
|
{
|
|
_logger.Biz(BizChannel.Attendance,
|
|
"CONNECTING",
|
|
"Machine ID : " + machineId,
|
|
"Machine IP : " + ip,
|
|
"Port : " + port,
|
|
"");
|
|
}
|
|
|
|
private async Task TemplateFetchSchedulerLoop(CancellationToken token)
|
|
{
|
|
bool firstRun = true;
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
if (!firstRun)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromHours(_config.TemplateFetchIntervalHours), token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
firstRun = false;
|
|
|
|
var cycleStarted = DateTime.Now;
|
|
int okCount = 0, failCount = 0;
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb, "JOB CYCLE START direction=DEVICE -> DB");
|
|
_logger.Biz(BizChannel.Template,
|
|
"TEMPLATE SYNC",
|
|
"",
|
|
"Direction :",
|
|
"",
|
|
"DEVICE -> DATABASE",
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Template);
|
|
foreach (var s in _sessions.ToArray())
|
|
{
|
|
if (token.IsCancellationRequested)
|
|
break;
|
|
|
|
try
|
|
{
|
|
_logger.Biz(BizChannel.Template,
|
|
"Device :",
|
|
"",
|
|
(s.Device.Ip ?? ""),
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Template);
|
|
var outDir = Path.Combine(_config.LogDirectory, "template_fetch", DateTime.Now.ToString("yyyy-MM-dd"), s.Device.DeviceId);
|
|
_logger.Diag("template_fetch", "Start device=" + s.Device.DeviceId + " outDir=\"" + outDir + "\"");
|
|
|
|
string jsonPath;
|
|
int discoveredUsers;
|
|
string err;
|
|
bool ok = TryExportAllUsersTemplatesToIsapiFile(
|
|
s.Device.DeviceId,
|
|
outDir,
|
|
30,
|
|
5000,
|
|
token,
|
|
out jsonPath,
|
|
out discoveredUsers,
|
|
out err);
|
|
|
|
if (ok)
|
|
{
|
|
okCount++;
|
|
TryUpdateMachineRuntimeState(s.Device.Ip, "IDLE", null, discoveredUsers);
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
"device=" + s.Device.DeviceId + " ip=" + (s.Device.Ip ?? "") +
|
|
" users=" + discoveredUsers + " status=OK");
|
|
}
|
|
else
|
|
{
|
|
failCount++;
|
|
_logger.OpsError(OpsMarkers.TemplateDeviceToDb,
|
|
"device=" + s.Device.DeviceId + " FAILED reason=\"" + err + "\"");
|
|
_logger.Biz(BizChannel.Template,
|
|
"Template sync failed for this device.",
|
|
"",
|
|
"Reason :",
|
|
"",
|
|
string.IsNullOrWhiteSpace(err) ? "Unknown error." : err,
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Template);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failCount++;
|
|
_logger.OpsError(OpsMarkers.TemplateDeviceToDb,
|
|
"device=" + s.Device.DeviceId + " FAILED reason=\"" + ex.Message + "\"");
|
|
}
|
|
}
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb, "JOB CYCLE END success=" + okCount +
|
|
" skipped=0 failed=" + failCount + " duration=" + FormatDuration(DateTime.Now - cycleStarted));
|
|
}
|
|
}
|
|
|
|
private void LogJobCycleStart(string jobKey, string jobTitle, string meaning)
|
|
{
|
|
var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
|
_logger.JobInfo(jobKey, "------------------------------------------------------------");
|
|
_logger.JobInfo(jobKey, "JOB CYCLE START: " + jobTitle);
|
|
_logger.JobInfo(jobKey, "Meaning: " + meaning);
|
|
_logger.JobInfo(jobKey, "Started at: " + now);
|
|
_logger.JobInfo(jobKey, "------------------------------------------------------------");
|
|
}
|
|
|
|
private void LogJobCycleEnd(string jobKey, string jobTitle, string meaning)
|
|
{
|
|
var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
|
_logger.JobInfo(jobKey, "------------------------------------------------------------");
|
|
_logger.JobInfo(jobKey, "JOB CYCLE END: " + jobTitle);
|
|
_logger.JobInfo(jobKey, "Meaning: " + meaning);
|
|
_logger.JobInfo(jobKey, "Finished at: " + now);
|
|
_logger.JobInfo(jobKey, "------------------------------------------------------------");
|
|
}
|
|
|
|
private bool PersistHistoricalAttendanceEvent(AttendanceEvent ev, out bool dbOk, AttendanceDeviceCycleStats? stats = null)
|
|
{
|
|
dbOk = true;
|
|
if (ev == null)
|
|
return false;
|
|
|
|
if (!_dedupeKeys.TryAdd(ev.DedupeKey, 1))
|
|
{
|
|
if (stats != null) stats.Duplicates++;
|
|
return false;
|
|
}
|
|
|
|
if (_dedupeKeys.Count > DedupeMaxEntries)
|
|
{
|
|
_dedupeKeys.Clear();
|
|
_dedupeKeys.TryAdd(ev.DedupeKey, 1);
|
|
}
|
|
|
|
lock (_csvWriteLock)
|
|
{
|
|
AppendAttendanceCsvLineSafely(ToCsvLine(ev));
|
|
}
|
|
|
|
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
|
|
AppendAttendanceToTextFileSafely(ev);
|
|
|
|
var outcome = WriteAttendanceToDatabase(ev, stats);
|
|
switch (outcome)
|
|
{
|
|
case AttendancePersistOutcome.DbInserted:
|
|
if (stats != null) { stats.ValidPunches++; stats.DbInserted++; }
|
|
dbOk = true;
|
|
TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null);
|
|
break;
|
|
case AttendancePersistOutcome.Duplicate:
|
|
if (stats != null) stats.Duplicates++;
|
|
dbOk = true;
|
|
break;
|
|
case AttendancePersistOutcome.SkippedNoEmployee:
|
|
case AttendancePersistOutcome.UnmappedEmployee:
|
|
case AttendancePersistOutcome.UnsupportedWorkerType:
|
|
if (stats != null) stats.SystemEventsSkipped++;
|
|
dbOk = true;
|
|
break;
|
|
case AttendancePersistOutcome.DbFailed:
|
|
if (stats != null) { stats.ValidPunches++; stats.Failed++; }
|
|
dbOk = false;
|
|
break;
|
|
case AttendancePersistOutcome.PersistedWithoutDb:
|
|
if (stats != null) stats.ValidPunches++;
|
|
dbOk = true;
|
|
break;
|
|
default:
|
|
dbOk = true;
|
|
break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool TryCleanupDeviceAttendanceStorage(DeviceSession session, DateTime checkTimeLocal, out string error)
|
|
{
|
|
error = "";
|
|
var body = "{ \"EventStorageCfg\": { " +
|
|
"\"mode\": \"time\", " +
|
|
"\"checkTime\": \"" + checkTimeLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + "\"" +
|
|
" } }";
|
|
var raw = StdXmlCall(session.UserId, "PUT", "/ISAPI/AccessControl/AcsEvent/StorageCfg?format=json", body, out var sdkErr);
|
|
var status = ExtractIsapiStatusSummary(raw);
|
|
if (!string.IsNullOrWhiteSpace(sdkErr))
|
|
{
|
|
error = sdkErr + " ; " + status;
|
|
return false;
|
|
}
|
|
|
|
if (status.IndexOf("statusCode=1", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
status.IndexOf("statusString=OK", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
return true;
|
|
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
return true;
|
|
|
|
error = "cleanup status not confirmed: " + status;
|
|
return false;
|
|
}
|
|
|
|
private string GetLastSyncFilePath(string deviceId)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
key = (deviceId ?? "").Trim();
|
|
|
|
// Make sure the key is file-system safe.
|
|
foreach (var ch in Path.GetInvalidFileNameChars())
|
|
key = key.Replace(ch, '_');
|
|
|
|
return Path.Combine(_config.LogDirectory, "last_sync_acs_" + key + ".txt");
|
|
}
|
|
|
|
private DateTime? ReadLastSyncTimestamp(string deviceId, out string reason)
|
|
{
|
|
reason = "";
|
|
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
|
|
{
|
|
var machineIp = ResolveMachineIpForDbLookup(deviceId);
|
|
if (string.IsNullOrWhiteSpace(machineIp))
|
|
{
|
|
reason = "db lookup skipped: machine_ip unresolved for deviceId=" + deviceId;
|
|
return null;
|
|
}
|
|
|
|
if (_attendanceMachineRepository.TryGetMachineLastSyncDate(machineIp, out var dbLastSync, out var dbErr))
|
|
{
|
|
if (dbLastSync.HasValue)
|
|
{
|
|
reason = "db.last_sync_date(machine_ip=" + machineIp + ")";
|
|
return dbLastSync.Value;
|
|
}
|
|
reason = "db.last_sync_date is null (machine_ip=" + machineIp + ")";
|
|
return null;
|
|
}
|
|
|
|
reason = "db last_sync_date read failed (machine_ip=" + machineIp + "): " + dbErr;
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var path = GetLastSyncFilePath(deviceId);
|
|
if (!File.Exists(path))
|
|
{
|
|
reason = "last-sync file missing";
|
|
return null;
|
|
}
|
|
|
|
var raw = File.ReadAllText(path).Trim();
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
reason = "last-sync file empty";
|
|
return null;
|
|
}
|
|
|
|
// Store as local time in "yyyy-MM-dd HH:mm:ss".
|
|
if (DateTime.TryParseExact(raw, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeLocal, out var dt))
|
|
return dt;
|
|
|
|
if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt))
|
|
return dt;
|
|
|
|
reason = "last-sync timestamp parse failed: raw=\"" + raw + "\"";
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
reason = "last-sync read failed: " + ex.Message;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal)
|
|
{
|
|
var now = DateTime.Now;
|
|
var localValue = NormalizeForDbLocalTime(timestampLocal);
|
|
if (localValue > now)
|
|
{
|
|
_logger.OpsWarn(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_WINDOW] Refusing future last_sync_date write" +
|
|
" machine_id=" + deviceId +
|
|
" proposed=" + localValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" clamped_to=" + now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
|
localValue = now;
|
|
}
|
|
|
|
localValue = DateTime.SpecifyKind(localValue, DateTimeKind.Unspecified);
|
|
|
|
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
|
|
{
|
|
var machineIp = ResolveMachineIpForDbLookup(deviceId);
|
|
if (string.IsNullOrWhiteSpace(machineIp))
|
|
{
|
|
_logger.Warn("last_sync_date DB update skipped: machine_ip unresolved for deviceId=" + deviceId);
|
|
return;
|
|
}
|
|
if (!_attendanceMachineRepository.UpdateMachineLastSyncDate(machineIp, localValue, out var dbErr))
|
|
_logger.Warn("last_sync_date DB update failed machine_ip=" + machineIp + " err=" + dbErr);
|
|
return;
|
|
}
|
|
|
|
var path = GetLastSyncFilePath(deviceId);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory);
|
|
var raw = localValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
|
File.WriteAllText(path, raw);
|
|
}
|
|
|
|
private int FetchAttendanceRecordsCore(
|
|
string deviceId,
|
|
DateTime fromLocal,
|
|
DateTime toLocal,
|
|
CancellationToken cancellationToken,
|
|
out DateTime? lastEventTimestamp,
|
|
out bool dbInsertAllSucceeded,
|
|
AttendanceDeviceCycleStats? stats = null)
|
|
{
|
|
lastEventTimestamp = null;
|
|
dbInsertAllSucceeded = true;
|
|
var canonicalKey = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
_logger.Diag("attendance", "FetchAttendanceRecordsCore device=" + deviceId + " key=" + canonicalKey);
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
_logger.OpsError(OpsMarkers.Attendance, "device=" + deviceId + " FAILED reason=\"not logged in\"");
|
|
return 0;
|
|
}
|
|
|
|
if (toLocal < fromLocal)
|
|
(fromLocal, toLocal) = (toLocal, fromLocal);
|
|
|
|
_logger.Diag("attendance", "Fetch START device=" + deviceId + " from=" + fromLocal.ToString("yyyy-MM-dd HH:mm:ss") +
|
|
" to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
// Prioritize the officially documented STDXMLConfig + ISAPI AccessControl path for DS-K1T642MFW.
|
|
// If anything fails (SDK call error, parse error, unexpected response), we fall back to NET_DVR_GET_ACS_EVENT.
|
|
if (IsStdXmlPreferredDevice(session.Device))
|
|
{
|
|
bool attemptedStdXml;
|
|
DateTime? stdLastEventTs;
|
|
int stdXmlParsed = TryStdXmlAcsFetchAndEnqueue(
|
|
session,
|
|
fromLocal,
|
|
toLocal,
|
|
cancellationToken,
|
|
out attemptedStdXml,
|
|
out stdLastEventTs,
|
|
out dbInsertAllSucceeded,
|
|
stats);
|
|
if (attemptedStdXml)
|
|
{
|
|
lastEventTimestamp = stdLastEventTs;
|
|
return stdXmlParsed;
|
|
}
|
|
}
|
|
|
|
DateTime? maxEventTs = null;
|
|
|
|
var cond = new CHCNetSDK.NET_DVR_ACS_EVENT_COND();
|
|
cond.Init();
|
|
cond.dwSize = (uint)Marshal.SizeOf(cond);
|
|
cond.dwMajor = _config.AcsHistoryMajor;
|
|
cond.dwMinor = _config.AcsHistoryMinor;
|
|
cond.struStartTime = ToDvrTime(fromLocal);
|
|
cond.struEndTime = ToDvrTime(toLocal);
|
|
cond.byPicEnable = 0;
|
|
cond.szMonitorID = "";
|
|
cond.wInductiveEventType = 65535;
|
|
|
|
IntPtr condPtr = IntPtr.Zero;
|
|
int handle = -1;
|
|
int total = 0;
|
|
int parsedOk = 0;
|
|
int parseFail = 0;
|
|
|
|
try
|
|
{
|
|
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
|
|
Marshal.StructureToPtr(cond, condPtr, false);
|
|
|
|
handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig(
|
|
session.UserId,
|
|
(uint)CHCNetSDK.NET_DVR_GET_ACS_EVENT,
|
|
condPtr,
|
|
(int)cond.dwSize,
|
|
null,
|
|
IntPtr.Zero);
|
|
|
|
if (handle < 0)
|
|
{
|
|
LogSdkFailure("NET_DVR_StartRemoteConfig(NET_DVR_GET_ACS_EVENT)", Common.CHCNetSDK.NET_DVR_GetLastError(),
|
|
session.Device.DeviceId, session.Device.Ip, "Historical fetch remote-config session creation failed.", isWarning: false);
|
|
return 0;
|
|
}
|
|
|
|
int cfgSize = Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_CFG));
|
|
IntPtr cfgPtr = Marshal.AllocHGlobal(cfgSize);
|
|
try
|
|
{
|
|
PrepareAcsEventCfgPointer(cfgPtr, cfgSize);
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, cfgPtr, (uint)cfgSize);
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
|
|
{
|
|
var cfg = Marshal.PtrToStructure<CHCNetSDK.NET_DVR_ACS_EVENT_CFG>(cfgPtr);
|
|
total++;
|
|
if (TryBuildAttendanceFromAcsCfg(session, ref cfg, out var ev))
|
|
{
|
|
if (PersistHistoricalAttendanceEvent(ev, out var dbInserted, stats))
|
|
{
|
|
parsedOk++;
|
|
if ((_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence) && !dbInserted)
|
|
dbInsertAllSucceeded = false;
|
|
}
|
|
|
|
if (ev != null)
|
|
{
|
|
if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value)
|
|
maxEventTs = ev.Timestamp;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
parseFail++;
|
|
if (stats != null) stats.SystemEventsSkipped++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
|
|
{
|
|
Thread.Sleep(200);
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
|
|
{
|
|
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
|
|
handle = -1;
|
|
break;
|
|
}
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FAILED)
|
|
{
|
|
LogSdkFailure("NET_DVR_GetNextRemoteConfig", Common.CHCNetSDK.NET_DVR_GetLastError(),
|
|
session.Device.DeviceId, session.Device.Ip, "Historical fetch remote-config read failed.", isWarning: false);
|
|
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
|
|
handle = -1;
|
|
break;
|
|
}
|
|
|
|
LogSdkFailure("NET_DVR_GetNextRemoteConfig", Common.CHCNetSDK.NET_DVR_GetLastError(),
|
|
session.Device.DeviceId, session.Device.Ip,
|
|
"Historical fetch returned unexpected SDK status=" + status + ".", isWarning: true);
|
|
Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle);
|
|
handle = -1;
|
|
break;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Marshal.FreeHGlobal(cfgPtr);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (condPtr != IntPtr.Zero)
|
|
{
|
|
Marshal.FreeHGlobal(condPtr);
|
|
}
|
|
|
|
if (handle >= 0)
|
|
{
|
|
try { Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
_logger.Info("Historical ACS fetch DONE device=" + deviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail);
|
|
_logger.JobInfo("attendance", "Fetch DONE device=" + deviceId + " rawRows=" + total + " parsedOk=" + parsedOk + " parseSkipped=" + parseFail);
|
|
lastEventTimestamp = maxEventTs;
|
|
return parsedOk;
|
|
}
|
|
|
|
private static bool IsStdXmlPreferredDevice(HikvisionAttendanceWindowsService.DeviceConfig device)
|
|
{
|
|
if (device == null)
|
|
return false;
|
|
|
|
// Prefer model/identity strings; DeviceId for this project usually starts with "DS-K1T642MFW-...".
|
|
var m = device.Model ?? string.Empty;
|
|
if (m.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
return true;
|
|
|
|
var did = device.DeviceId ?? string.Empty;
|
|
if (did.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
return true;
|
|
|
|
var serial = device.SerialNumber ?? string.Empty;
|
|
if (serial.IndexOf("DS-K1T642MFW", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// STDXMLConfig diagnostic path using ISAPI/AccessControl endpoints.
|
|
/// Returns parsed attendance event count, and sets attemptedStdXml=true only if we successfully parsed at least one event.
|
|
/// </summary>
|
|
private int TryStdXmlAcsFetchAndEnqueue(
|
|
DeviceSession session,
|
|
DateTime fromLocal,
|
|
DateTime toLocal,
|
|
CancellationToken cancellationToken,
|
|
out bool attemptedStdXml,
|
|
out DateTime? lastEventTimestamp,
|
|
out bool dbInsertAllSucceeded,
|
|
AttendanceDeviceCycleStats? stats = null)
|
|
{
|
|
attemptedStdXml = false;
|
|
lastEventTimestamp = null;
|
|
dbInsertAllSucceeded = true;
|
|
|
|
try
|
|
{
|
|
// Step 1-3: capabilities (raw JSON/XML is logged).
|
|
// Step 5-6: build request bodies and POST.
|
|
// Step 7: log request URL/body + raw response + parsed response status.
|
|
|
|
uint major = _config.AcsHistoryMajor;
|
|
uint minor = _config.AcsHistoryMinor;
|
|
|
|
//var fromUtc = fromLocal.ToUniversalTime();
|
|
//var toUtc = toLocal.ToUniversalTime();
|
|
|
|
// Query window in local wall-clock with this host's UTC offset (Pakistan = +05:00).
|
|
// Do not convert punches to UTC; attendance_log must match the time shown on the device.
|
|
var localOffset = TimeZoneInfo.Local.GetUtcOffset(fromLocal);
|
|
var offsetSign = localOffset < TimeSpan.Zero ? "-" : "+";
|
|
var offsetAbs = localOffset.Duration();
|
|
var offsetText = offsetSign +
|
|
offsetAbs.Hours.ToString("00", CultureInfo.InvariantCulture) + ":" +
|
|
offsetAbs.Minutes.ToString("00", CultureInfo.InvariantCulture);
|
|
string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + offsetText;
|
|
string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + offsetText;
|
|
|
|
var searchId = "1";
|
|
int searchResultPosition = 0;
|
|
int maxResults = 30; // dynamic (see loop)
|
|
|
|
string r1 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/capabilities?format=json", null, out var e1);
|
|
_logger.Info("STDXMLConfig step1 rawResponse: " + TruncateForLog(r1, 120_000));
|
|
if (!string.IsNullOrEmpty(e1))
|
|
LogSdkFailureFromText("NET_DVR_STDXMLConfig step1 GET /ISAPI/AccessControl/capabilities", e1, session.Device.DeviceId, session.Device.Ip, isWarning: true);
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
string r2 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEvent/capabilities?format=json", null, out var e2);
|
|
_logger.Info("STDXMLConfig step2 rawResponse: " + TruncateForLog(r2, 120_000));
|
|
if (!string.IsNullOrEmpty(e2))
|
|
LogSdkFailureFromText("NET_DVR_STDXMLConfig step2 GET /ISAPI/AccessControl/AcsEvent/capabilities", e2, session.Device.DeviceId, session.Device.Ip, isWarning: true);
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
string r3 = StdXmlCall(session.UserId, "GET", "/ISAPI/AccessControl/AcsEventTotalNum/capabilities?format=json", null, out var e3);
|
|
_logger.Info("STDXMLConfig step3 rawResponse: " + TruncateForLog(r3, 120_000));
|
|
if (!string.IsNullOrEmpty(e3))
|
|
LogSdkFailureFromText("NET_DVR_STDXMLConfig step3 GET /ISAPI/AccessControl/AcsEventTotalNum/capabilities", e3, session.Device.DeviceId, session.Device.Ip, isWarning: true);
|
|
|
|
// Step 5: build JSON_AcsEventTotalNumCond and POST.
|
|
string jsonTotalNumCond = BuildJsonAcsEventTotalNumCond(searchId, major, minor, startTime, endTime);
|
|
string totalNumUri = "/ISAPI/AccessControl/AcsEventTotalNum?format=json";
|
|
string totalNumRequestUrl = "POST " + totalNumUri;
|
|
_logger.Info("STDXMLConfig step5 requestUrl=" + totalNumRequestUrl + " requestBody=" + TruncateForLog(jsonTotalNumCond, 120_000));
|
|
|
|
string rTotalNum = StdXmlCall(session.UserId, "POST", totalNumUri, jsonTotalNumCond, out var eTotalNum);
|
|
_logger.Info("STDXMLConfig step5 rawResponse: " + TruncateForLog(rTotalNum, 120_000));
|
|
if (!string.IsNullOrEmpty(eTotalNum))
|
|
LogSdkFailureFromText("NET_DVR_STDXMLConfig step5 POST /ISAPI/AccessControl/AcsEventTotalNum", eTotalNum, session.Device.DeviceId, session.Device.Ip, isWarning: true);
|
|
_logger.Info("STDXMLConfig step5 parsedResponseStatus: " + ParseStdXmlResponseStatus(rTotalNum));
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Step 6: build JSON_AcsEventCond and POST (paged), stopping when responseStatusStrg == END.
|
|
// Dynamic batch size: start with 30; after we fetch enough rows, increase batch to reduce round-trips.
|
|
string acsEventUri = "/ISAPI/AccessControl/AcsEvent?format=json";
|
|
int total = 0;
|
|
int parsedOk = 0;
|
|
int parseFail = 0;
|
|
DateTime? maxEventTs = null;
|
|
|
|
int? totalMatches = TryExtractTotalMatchesFromAcsTotalNum(rTotalNum);
|
|
if (totalMatches.HasValue)
|
|
_logger.Info("STDXMLConfig step5 totalMatches=" + totalMatches.Value);
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
maxResults = DetermineDynamicAcsBatchSize(parsedOk, maxResults);
|
|
string jsonAcsEventCond = BuildJsonAcsEventCond(searchId, searchResultPosition, maxResults, major, minor, startTime, endTime);
|
|
string acsEventRequestUrl = "POST " + acsEventUri;
|
|
_logger.Info("STDXMLConfig step6 requestUrl=" + acsEventRequestUrl +
|
|
" searchResultPosition=" + searchResultPosition +
|
|
" maxResults=" + maxResults +
|
|
" requestBody=" + TruncateForLog(jsonAcsEventCond, 120_000));
|
|
|
|
string rAcsEvent = StdXmlCall(session.UserId, "POST", acsEventUri, jsonAcsEventCond, out var eAcsEvent);
|
|
_logger.Info("STDXMLConfig step6 rawResponse: " + TruncateForLog(rAcsEvent, 120_000));
|
|
if (!string.IsNullOrEmpty(eAcsEvent))
|
|
LogSdkFailureFromText("NET_DVR_STDXMLConfig step6 POST /ISAPI/AccessControl/AcsEvent", eAcsEvent, session.Device.DeviceId, session.Device.Ip, isWarning: true);
|
|
|
|
string statusStr = ParseStdXmlResponseStatus(rAcsEvent);
|
|
_logger.Info("STDXMLConfig step6 parsedResponseStatus: " + statusStr);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var acsEventInfoList = ExtractAcsEventInfoList(rAcsEvent);
|
|
_logger.Info("STDXMLConfig extracted events: " + acsEventInfoList.Count);
|
|
|
|
// Step 10 (goal): map returned fields into our attendance pipeline.
|
|
string eventName = MapAcsEventName(major, minor);
|
|
if (string.IsNullOrWhiteSpace(eventName))
|
|
eventName = "MAJOR_" + major + "_MINOR_" + minor;
|
|
string eventType = AcsAttendanceParser.MapMajorCategory(major) + "/" + minor.ToString("X");
|
|
bool isSuccessByMinorRule = AcsAttendanceParser.ResolveIsSuccess(major, minor, eventName);
|
|
|
|
int pageParsedOk = 0;
|
|
foreach (var info in acsEventInfoList)
|
|
{
|
|
total++;
|
|
if (!TryBuildAttendanceFromStdAcsInfo(session.Device, info, major, minor, eventName, eventType,
|
|
isSuccessByMinorRule, out var ev))
|
|
{
|
|
parseFail++;
|
|
if (stats != null) stats.SystemEventsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
if (PersistHistoricalAttendanceEvent(ev, out var dbInserted, stats))
|
|
{
|
|
parsedOk++;
|
|
pageParsedOk++;
|
|
if ((_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence) && !dbInserted)
|
|
dbInsertAllSucceeded = false;
|
|
}
|
|
|
|
if (!maxEventTs.HasValue || ev.Timestamp > maxEventTs.Value)
|
|
maxEventTs = ev.Timestamp;
|
|
}
|
|
|
|
// Pagination: advance by what the device returned (avoids duplication even if it returns fewer than maxResults).
|
|
if (acsEventInfoList.Count > 0)
|
|
searchResultPosition += acsEventInfoList.Count;
|
|
else
|
|
{
|
|
// Defensive: if device claims MORE but returns 0, stop to avoid infinite loop.
|
|
_logger.Warn("STDXMLConfig paging: got 0 events; stopping to avoid loop. status=" + statusStr +
|
|
" pos=" + searchResultPosition + " max=" + maxResults);
|
|
break;
|
|
}
|
|
|
|
// Stop conditions
|
|
if (statusStr.IndexOf("END", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
break;
|
|
if (statusStr.IndexOf("MORE", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
continue;
|
|
|
|
// Unknown status: stop; caller will treat this attempt as successful if we parsed anything.
|
|
_logger.Warn("STDXMLConfig paging: unexpected responseStatusStrg=\"" + statusStr + "\"; stopping.");
|
|
break;
|
|
}
|
|
|
|
_logger.Info("STDXMLConfig fetch DONE device=" + session.Device.DeviceId + " rawRows=" + total + ", parsedOk=" + parsedOk + ", parseSkipped=" + parseFail);
|
|
|
|
// Treat the STDXML attempt as authoritative if we reached here without throwing,
|
|
// even if the date range has no events (avoid redundant SDK remote-config fetch).
|
|
attemptedStdXml = true;
|
|
if (maxEventTs.HasValue)
|
|
// Event timestamps are stored as device/local wall-clock for attendance — keep as-is.
|
|
lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Local);
|
|
else
|
|
lastEventTimestamp = null;
|
|
return parsedOk;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("STDXMLConfig diagnostic fetch failed; falling back to NET_DVR_GET_ACS_EVENT", ex);
|
|
attemptedStdXml = false;
|
|
lastEventTimestamp = null;
|
|
dbInsertAllSucceeded = false;
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private static int DetermineDynamicAcsBatchSize(int totalParsedOkSoFar, int currentMaxResults)
|
|
{
|
|
// Conservative dynamic batching: keep requests small until we know the window is large.
|
|
// The AcsEvent ISAPI may return large payloads; this avoids overwhelming the device.
|
|
if (totalParsedOkSoFar < 1000)
|
|
return 30;
|
|
if (totalParsedOkSoFar < 5000)
|
|
return 50;
|
|
return 100;
|
|
}
|
|
|
|
private static int? TryExtractTotalMatchesFromAcsTotalNum(string responseJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(responseJson))
|
|
return null;
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? obj = ser.DeserializeObject(responseJson);
|
|
if (obj == null)
|
|
return null;
|
|
if (TryFindInt(obj, new[] { "totalMatches", "totalMatch", "totalNum", "total", "matchNum" }, out var i))
|
|
return i;
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string BuildJsonAcsEventTotalNumCond(string searchId, uint major, uint minor, string startTimeUtc, string endTimeUtc)
|
|
{
|
|
return "{ \"AcsEventTotalNumCond\": { " +
|
|
"\"searchID\": \"" + EscapeJson(searchId) + "\"," +
|
|
"\"major\": " + major + "," +
|
|
"\"minor\": " + minor + "," +
|
|
"\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," +
|
|
"\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" +
|
|
" } }";
|
|
}
|
|
|
|
private static string BuildJsonAcsEventCond(
|
|
string searchId,
|
|
int searchResultPosition,
|
|
int maxResults,
|
|
uint major,
|
|
uint minor,
|
|
string startTimeUtc,
|
|
string endTimeUtc)
|
|
{
|
|
//string minorPart = minor != 0 ? ",\"minor\": " + minor : "";
|
|
string minorPart = ",\"minor\": " + minor;
|
|
return "{ \"AcsEventCond\": { " +
|
|
"\"searchID\": \"" + EscapeJson(searchId) + "\"," +
|
|
"\"searchResultPosition\": " + searchResultPosition + "," +
|
|
"\"maxResults\": " + maxResults + "," +
|
|
"\"major\": " + major +
|
|
minorPart + "," +
|
|
"\"startTime\": \"" + EscapeJson(startTimeUtc) + "\"," +
|
|
"\"endTime\": \"" + EscapeJson(endTimeUtc) + "\"" +
|
|
" } }";
|
|
}
|
|
|
|
private static string EscapeJson(string value)
|
|
{
|
|
if (value == null)
|
|
return "";
|
|
|
|
return value
|
|
.Replace("\\", "\\\\")
|
|
.Replace("\"", "\\\"")
|
|
.Replace("\r", "\\r")
|
|
.Replace("\n", "\\n")
|
|
.Replace("\t", "\\t");
|
|
}
|
|
|
|
private string StdXmlCall(int userId, string method, string uri, string? postBody, out string sdkError)
|
|
{
|
|
sdkError = "";
|
|
string raw = "";
|
|
|
|
IntPtr ptrUrl = IntPtr.Zero;
|
|
IntPtr ptrIn = IntPtr.Zero;
|
|
IntPtr ptrInput = IntPtr.Zero;
|
|
IntPtr ptrOutBuf = IntPtr.Zero;
|
|
IntPtr ptrOutput = IntPtr.Zero;
|
|
|
|
try
|
|
{
|
|
// Input
|
|
var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT();
|
|
input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT));
|
|
|
|
string requestUrl = method + " " + uri;
|
|
ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl);
|
|
input.lpRequestUrl = ptrUrl;
|
|
input.dwRequestUrlLen = (uint)requestUrl.Length;
|
|
input.dwRecvTimeOut = 5000; // ms
|
|
|
|
if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody))
|
|
{
|
|
ptrIn = Marshal.StringToCoTaskMemAnsi(postBody);
|
|
input.lpInBuffer = ptrIn;
|
|
input.dwInBufferSize = (uint)postBody.Length;
|
|
}
|
|
else
|
|
{
|
|
input.lpInBuffer = IntPtr.Zero;
|
|
input.dwInBufferSize = 0;
|
|
}
|
|
|
|
ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)));
|
|
Marshal.StructureToPtr(input, ptrInput, false);
|
|
|
|
// Output
|
|
var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT();
|
|
output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT));
|
|
|
|
const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw JSON/XML responses
|
|
ptrOutBuf = Marshal.AllocHGlobal(outBufSize);
|
|
output.lpOutBuffer = ptrOutBuf;
|
|
output.dwOutBufferSize = (uint)outBufSize;
|
|
output.lpStatusBuffer = ptrOutBuf;
|
|
output.dwStatusSize = (uint)outBufSize;
|
|
|
|
ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)));
|
|
Marshal.StructureToPtr(output, ptrOutput, false);
|
|
|
|
bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput);
|
|
if (!ok)
|
|
{
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
sdkError = "NET_DVR_STDXMLConfig failed err=" + err;
|
|
}
|
|
|
|
var outAfter = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT>(ptrOutput);
|
|
int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize);
|
|
if (returnedSize > 0 && returnedSize <= outBufSize)
|
|
{
|
|
byte[] bytes = new byte[returnedSize];
|
|
Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize);
|
|
raw = Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim();
|
|
}
|
|
else
|
|
{
|
|
raw = Marshal.PtrToStringAnsi(ptrOutBuf) ?? "";
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl);
|
|
if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn);
|
|
if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput);
|
|
if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput);
|
|
if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf);
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
private byte[] StdXmlCallBytes(int userId, string method, string uri, string? postBody, out string sdkError)
|
|
{
|
|
sdkError = "";
|
|
byte[] bytes = Array.Empty<byte>();
|
|
|
|
IntPtr ptrUrl = IntPtr.Zero;
|
|
IntPtr ptrIn = IntPtr.Zero;
|
|
IntPtr ptrInput = IntPtr.Zero;
|
|
IntPtr ptrOutput = IntPtr.Zero;
|
|
IntPtr ptrOutBuf = IntPtr.Zero;
|
|
|
|
try
|
|
{
|
|
var input = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT();
|
|
input.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT));
|
|
|
|
string requestUrl = method + " " + uri;
|
|
ptrUrl = Marshal.StringToCoTaskMemAnsi(requestUrl);
|
|
input.lpRequestUrl = ptrUrl;
|
|
input.dwRequestUrlLen = (uint)requestUrl.Length;
|
|
input.dwRecvTimeOut = 5000; // ms
|
|
|
|
if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(postBody))
|
|
{
|
|
ptrIn = Marshal.StringToCoTaskMemAnsi(postBody);
|
|
input.lpInBuffer = ptrIn;
|
|
input.dwInBufferSize = (uint)postBody.Length;
|
|
}
|
|
else
|
|
{
|
|
input.lpInBuffer = IntPtr.Zero;
|
|
input.dwInBufferSize = 0;
|
|
}
|
|
|
|
ptrInput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_INPUT)));
|
|
Marshal.StructureToPtr(input, ptrInput, false);
|
|
|
|
var output = new Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT();
|
|
output.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT));
|
|
|
|
const int outBufSize = 4 * 1024 * 1024; // 4MB scratch for raw responses
|
|
ptrOutBuf = Marshal.AllocHGlobal(outBufSize);
|
|
output.lpOutBuffer = ptrOutBuf;
|
|
output.dwOutBufferSize = (uint)outBufSize;
|
|
output.lpStatusBuffer = ptrOutBuf;
|
|
output.dwStatusSize = (uint)outBufSize;
|
|
|
|
ptrOutput = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT)));
|
|
Marshal.StructureToPtr(output, ptrOutput, false);
|
|
|
|
bool ok = Common.CHCNetSDK.NET_DVR_STDXMLConfig(userId, ptrInput, ptrOutput);
|
|
if (!ok)
|
|
{
|
|
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
sdkError = "NET_DVR_STDXMLConfig failed err=" + err;
|
|
}
|
|
|
|
var outAfter = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_XML_CONFIG_OUTPUT>(ptrOutput);
|
|
int returnedSize = (int)Math.Min(outAfter.dwReturnedXMLSize, outBufSize);
|
|
if (returnedSize > 0 && returnedSize <= outBufSize)
|
|
{
|
|
bytes = new byte[returnedSize];
|
|
Marshal.Copy(outAfter.lpOutBuffer, bytes, 0, returnedSize);
|
|
}
|
|
else
|
|
{
|
|
bytes = Array.Empty<byte>();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (ptrUrl != IntPtr.Zero) Marshal.FreeHGlobal(ptrUrl);
|
|
if (ptrIn != IntPtr.Zero) Marshal.FreeHGlobal(ptrIn);
|
|
if (ptrInput != IntPtr.Zero) Marshal.FreeHGlobal(ptrInput);
|
|
if (ptrOutput != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutput);
|
|
if (ptrOutBuf != IntPtr.Zero) Marshal.FreeHGlobal(ptrOutBuf);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
private static int? TryExtractErrorCode17(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return null;
|
|
|
|
// Best-effort: Hikvision error responses often include "errorCode": 17 or <errorCode>17</errorCode>
|
|
// so we do a small heuristic scan.
|
|
try
|
|
{
|
|
int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
return null;
|
|
|
|
// Search the nearest integer token after the marker.
|
|
var tail = text.Substring(idx);
|
|
// Simple tokenization: keep digits and '-' only.
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < tail.Length; i++)
|
|
{
|
|
char c = tail[i];
|
|
if (char.IsDigit(c) || c == '-')
|
|
sb.Append(c);
|
|
else if (sb.Length > 0)
|
|
break;
|
|
}
|
|
|
|
if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code))
|
|
{
|
|
if (code == 17)
|
|
return 17;
|
|
return code;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool LooksLikeJson(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return false;
|
|
var t = text.TrimStart();
|
|
return t.StartsWith("{") || t.StartsWith("[");
|
|
}
|
|
|
|
private bool TryParseFingerprintItemsFromIsapiResponse(byte[] responseBytes, out List<FingerprintTemplateExportItem> items, out string parseError)
|
|
{
|
|
items = new List<FingerprintTemplateExportItem>();
|
|
parseError = "";
|
|
|
|
if (responseBytes == null || responseBytes.Length == 0)
|
|
return false;
|
|
|
|
// Some firmware returns XML/JSON error messages as text; others return binary blob.
|
|
string text;
|
|
try
|
|
{
|
|
text = Encoding.UTF8.GetString(responseBytes);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// If it's not JSON, we treat it as a single opaque binary fingerprint template.
|
|
if (!LooksLikeJson(text))
|
|
{
|
|
items.Add(new FingerprintTemplateExportItem
|
|
{
|
|
fingerId = 1,
|
|
attempted = true,
|
|
present = true,
|
|
byteLength = responseBytes.Length,
|
|
dataBase64 = Convert.ToBase64String(responseBytes),
|
|
error = ""
|
|
});
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(text);
|
|
if (root == null)
|
|
return false;
|
|
|
|
static bool LooksLikeBase64(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s))
|
|
return false;
|
|
var t = s.Trim();
|
|
if (t.Length < 16)
|
|
return false;
|
|
// allow both standard and URL-safe base64 alphabets.
|
|
for (int i = 0; i < t.Length; i++)
|
|
{
|
|
char c = t[i];
|
|
if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
|
|
continue;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Best-effort traversal: find dictionaries with a finger id and a data/base64-like field.
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
|
|
var candidateFingerIds = new HashSet<int>();
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is Dictionary<string, object> nested)
|
|
stack.Push(nested);
|
|
if (kv.Value is object[] arr)
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
|
|
// attempt to parse finger id from current dictionary
|
|
int? fingerId = null;
|
|
if (TryGetIntNullable(d, "fingerPrintID", out var fid1))
|
|
fingerId = fid1;
|
|
else if (TryGetIntNullable(d, "fingerId", out var fid2))
|
|
fingerId = fid2;
|
|
else if (TryGetIntNullable(d, "fingerprintId", out var fid3))
|
|
fingerId = fid3;
|
|
|
|
// attempt to find any base64-looking string
|
|
if (fingerId.HasValue && fingerId.Value >= 0)
|
|
{
|
|
string? base64 = null;
|
|
|
|
// Fast paths for known key names.
|
|
if (d.TryGetValue("dataBase64", out var db) && db is string s1 && LooksLikeBase64(s1))
|
|
base64 = s1;
|
|
else if (d.TryGetValue("fingerData", out var fd) && fd is string s2 && LooksLikeBase64(s2))
|
|
base64 = s2;
|
|
|
|
// Broader heuristic: any base64-looking value in keys that suggest template/bio content.
|
|
if (base64 == null)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is not string ss)
|
|
continue;
|
|
if (!LooksLikeBase64(ss))
|
|
continue;
|
|
var key = kv.Key ?? "";
|
|
var k = key.ToLowerInvariant();
|
|
if (k.Contains("base64") || k.Contains("finger") || k.Contains("template") || k.Contains("data"))
|
|
{
|
|
base64 = ss;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(base64))
|
|
{
|
|
candidateFingerIds.Add(fingerId.Value);
|
|
items.Add(new FingerprintTemplateExportItem
|
|
{
|
|
fingerId = (byte)Math.Max(0, Math.Min(10, fingerId.Value)),
|
|
attempted = true,
|
|
present = true,
|
|
dataBase64 = base64,
|
|
byteLength = -1,
|
|
error = ""
|
|
});
|
|
}
|
|
}
|
|
}
|
|
else if (cur is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
|
|
if (items.Count > 0)
|
|
return true;
|
|
|
|
parseError = "fingerprint JSON parse found no per-finger template objects";
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
parseError = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool TryGetIntNullable(Dictionary<string, object> d, string key, out int value)
|
|
{
|
|
value = 0;
|
|
if (!d.TryGetValue(key, out var v) || v == null)
|
|
return false;
|
|
if (v is int i) { value = i; return true; }
|
|
if (v is long l) { value = (int)l; return true; }
|
|
if (v is double dd) { value = (int)dd; return true; }
|
|
if (v is string s && int.TryParse(s, out var p)) { value = p; return true; }
|
|
return false;
|
|
}
|
|
|
|
private static string ExtractIsapiStatusSummary(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return "status=(empty)";
|
|
|
|
string statusCode = "";
|
|
string statusString = "";
|
|
string subStatusCode = "";
|
|
string errorCode = "";
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(text);
|
|
if (root != null)
|
|
{
|
|
if (TryFindInt(root, new[] { "statusCode" }, out var sCode)) statusCode = sCode.ToString(CultureInfo.InvariantCulture);
|
|
if (TryFindString(root, new[] { "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var sStr)) statusString = sStr;
|
|
if (TryFindString(root, new[] { "subStatusCode" }, out var sub)) subStatusCode = sub;
|
|
if (TryFindInt(root, new[] { "errorCode" }, out var e)) errorCode = e.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore and fallback to regex below
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(statusCode))
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusCode\"\\s*:\\s*(?<v>-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (m.Success) statusCode = m.Groups["v"].Value;
|
|
}
|
|
if (string.IsNullOrEmpty(statusString))
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, "\"statusString\"\\s*:\\s*\"(?<v>[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (m.Success) statusString = m.Groups["v"].Value;
|
|
}
|
|
if (string.IsNullOrEmpty(subStatusCode))
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, "\"subStatusCode\"\\s*:\\s*\"(?<v>[^\"]*)\"", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (m.Success) subStatusCode = m.Groups["v"].Value;
|
|
}
|
|
if (string.IsNullOrEmpty(errorCode))
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, "\"errorCode\"\\s*:\\s*(?<v>-?\\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (m.Success) errorCode = m.Groups["v"].Value;
|
|
}
|
|
|
|
return "statusCode=" + (string.IsNullOrEmpty(statusCode) ? "-" : statusCode) +
|
|
", statusString=" + (string.IsNullOrEmpty(statusString) ? "-" : statusString) +
|
|
", subStatusCode=" + (string.IsNullOrEmpty(subStatusCode) ? "-" : subStatusCode) +
|
|
", errorCode=" + (string.IsNullOrEmpty(errorCode) ? "-" : errorCode);
|
|
}
|
|
|
|
private static string ToOneLineSnippet(string? text, int maxLen = 800)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return "";
|
|
var s = text.Replace("\r", " ").Replace("\n", " ").Trim();
|
|
if (s.Length > maxLen)
|
|
s = s.Substring(0, maxLen);
|
|
return s;
|
|
}
|
|
|
|
private string ProbeCapabilityAndLog(int userId, string uri, string featureName, StreamWriter sw)
|
|
{
|
|
var raw = StdXmlCall(userId, "GET", uri, null, out var sdkErr);
|
|
var status = ExtractIsapiStatusSummary(raw);
|
|
var snippet = ToOneLineSnippet(raw);
|
|
sw.WriteLine(DateTime.UtcNow.ToString("o") + " feature=" + featureName + " capabilityUri=" + uri +
|
|
" sdkErr=" + (string.IsNullOrEmpty(sdkErr) ? "-" : sdkErr) +
|
|
" " + status + " raw_snip=\"" + snippet + "\"");
|
|
return raw;
|
|
}
|
|
|
|
private bool TrySearchCardInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error)
|
|
{
|
|
response = "";
|
|
error = "";
|
|
var body = "{ \"CardInfoSearchCond\": { " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 20, " +
|
|
"\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" +
|
|
" } }";
|
|
response = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/CardInfo/Search?format=json", body, out var sdkErr);
|
|
error = sdkErr;
|
|
return !string.IsNullOrWhiteSpace(response);
|
|
}
|
|
|
|
private bool TrySearchUserInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error)
|
|
{
|
|
response = "";
|
|
error = "";
|
|
// Try targeted search first.
|
|
var body1 = "{ \"UserInfoSearchCond\": { " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 5, " +
|
|
"\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" +
|
|
" } }";
|
|
var raw1 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var err1);
|
|
if (!string.IsNullOrWhiteSpace(raw1))
|
|
{
|
|
response = raw1;
|
|
error = err1;
|
|
return true;
|
|
}
|
|
|
|
// Fallback for firmware that expects UserInfoSearch wrapper and no employee filter.
|
|
var body2 = "{ \"UserInfoSearch\": { " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 200 " +
|
|
" } }";
|
|
var raw2 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var err2);
|
|
response = raw2;
|
|
error = string.IsNullOrWhiteSpace(err2) ? err1 : err2;
|
|
return !string.IsNullOrWhiteSpace(response);
|
|
}
|
|
|
|
private bool TrySearchUserInfoByEmployeeNoIsapiHttp(DeviceSession session, string employeeNo, out string response, out string error)
|
|
{
|
|
response = "";
|
|
error = "";
|
|
try
|
|
{
|
|
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
|
|
string url = "http://" + session.Device.Ip + ":" + httpPort + "/ISAPI/AccessControl/UserInfo/Search?format=json";
|
|
string body = "{ \"UserInfoSearchCond\": { \"searchID\": \"1\", \"searchResultPosition\": 0, \"maxResults\": 100 } }";
|
|
|
|
var handler = new HttpClientHandler
|
|
{
|
|
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
|
|
PreAuthenticate = false,
|
|
UseDefaultCredentials = false
|
|
};
|
|
|
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
|
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
|
};
|
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
|
response = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
|
|
if (!res.IsSuccessStatusCode)
|
|
{
|
|
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
|
|
return !string.IsNullOrWhiteSpace(response);
|
|
}
|
|
|
|
_logger.Info("ISAPI HTTP UserInfo/Search ok: device=" + session.Device.DeviceId + ", httpPort=" + httpPort +
|
|
", responseLen=" + response.Length + ", source=HttpDigest");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
_logger.Warn("ISAPI HTTP UserInfo/Search failed: device=" + session.Device.DeviceId + ", err=" + ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool TrySearchUserInfoByEmployeeNo(DeviceSession session, string employeeNo, out string response, out string error)
|
|
{
|
|
if (_config.UseIsapiHttpForUserInfo)
|
|
{
|
|
_logger.Info("UserInfo source: ISAPI HTTP Digest (UseIsapiHttpForUserInfo=true), device=" + session.Device.DeviceId);
|
|
if (TrySearchUserInfoByEmployeeNoIsapiHttp(session, employeeNo, out response, out error))
|
|
return true;
|
|
_logger.Warn("UserInfo HTTP source failed; fallback to SDK STDXML, device=" + session.Device.DeviceId +
|
|
", err=" + (string.IsNullOrEmpty(error) ? "-" : error));
|
|
}
|
|
|
|
return TrySearchUserInfoByEmployeeNoIsapi(session.UserId, employeeNo, out response, out error);
|
|
}
|
|
|
|
private bool TryDownloadFaceImageViaIsapiHttp(DeviceSession session, string faceUrl, out byte[] imageBytes, out string error)
|
|
{
|
|
imageBytes = Array.Empty<byte>();
|
|
error = "";
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
|
{
|
|
error = "empty faceUrl";
|
|
return false;
|
|
}
|
|
|
|
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
|
|
string pathAndQuery = faceUrl;
|
|
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var abs))
|
|
pathAndQuery = abs.PathAndQuery;
|
|
if (!pathAndQuery.StartsWith("/"))
|
|
pathAndQuery = "/" + pathAndQuery.TrimStart('/');
|
|
|
|
// Force configured device IP for HTTP calls.
|
|
string url = "http://" + session.Device.Ip + ":" + httpPort + pathAndQuery;
|
|
|
|
var handler = new HttpClientHandler
|
|
{
|
|
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
|
|
PreAuthenticate = false,
|
|
UseDefaultCredentials = false
|
|
};
|
|
|
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
|
imageBytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
|
if (!res.IsSuccessStatusCode)
|
|
{
|
|
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
|
|
return false;
|
|
}
|
|
return imageBytes.Length > 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryExtractFaceUrlFromUserInfoSearch(string userInfoJson, string employeeNo, out string faceUrl)
|
|
{
|
|
faceUrl = "";
|
|
if (string.IsNullOrWhiteSpace(userInfoJson))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(userInfoJson);
|
|
if (root == null)
|
|
return false;
|
|
|
|
var candidateKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"faceURL", "faceUrl", "pictureURL", "pictureUrl", "photoURL", "photoUrl", "imgUrl"
|
|
};
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
// Prefer extracting from matching user object.
|
|
bool employeeMatches = false;
|
|
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
|
|
{
|
|
var eno = enoObj.ToString() ?? "";
|
|
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
if (employeeMatches)
|
|
{
|
|
foreach (var k in candidateKeys)
|
|
{
|
|
if (d.TryGetValue(k, out var v) && v is string s && !string.IsNullOrWhiteSpace(s))
|
|
{
|
|
faceUrl = s.Trim();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is Dictionary<string, object> nd)
|
|
stack.Push(nd);
|
|
else if (kv.Value is object[] arr)
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
else if (cur is object[] arr2)
|
|
{
|
|
foreach (var it in arr2)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// best effort only
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool TryExtractNameFromUserInfoSearch(string userInfoJson, string employeeNo, out string name)
|
|
{
|
|
name = "";
|
|
if (string.IsNullOrWhiteSpace(userInfoJson))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(userInfoJson);
|
|
if (root == null)
|
|
return false;
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
bool employeeMatches = false;
|
|
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
|
|
{
|
|
var eno = enoObj.ToString() ?? "";
|
|
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
if (employeeMatches && d.TryGetValue("name", out var nameObj) && nameObj is string s && !string.IsNullOrWhiteSpace(s))
|
|
{
|
|
name = s.Trim();
|
|
return true;
|
|
}
|
|
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is Dictionary<string, object> nd)
|
|
stack.Push(nd);
|
|
else if (kv.Value is object[] arr)
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
else if (cur is object[] arr2)
|
|
{
|
|
foreach (var it in arr2)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// best effort only
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError)
|
|
{
|
|
return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError);
|
|
}
|
|
|
|
private string CardInfoSetUpIsapi(int userId, string jsonBody, out string sdkError)
|
|
{
|
|
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/SetUp?format=json", jsonBody, out sdkError);
|
|
}
|
|
|
|
private string CardInfoRecordIsapi(int userId, string jsonBody, out string sdkError)
|
|
{
|
|
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Record?format=json", jsonBody, out sdkError);
|
|
}
|
|
|
|
private string CardInfoModifyIsapi(int userId, string jsonBody, out string sdkError)
|
|
{
|
|
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Modify?format=json", jsonBody, out sdkError);
|
|
}
|
|
|
|
private string CardInfoDeleteIsapi(int userId, string jsonBody, out string sdkError)
|
|
{
|
|
return CallCardInfoApi(userId, "/ISAPI/AccessControl/CardInfo/Delete?format=json", jsonBody, out sdkError);
|
|
}
|
|
|
|
private bool TryFetchFingerprintTemplatesViaRemoteConfig(
|
|
DeviceSession session,
|
|
string cardNo,
|
|
out List<FingerprintTemplateExportItem> fingerprints,
|
|
out string debug)
|
|
{
|
|
fingerprints = new List<FingerprintTemplateExportItem>();
|
|
debug = "";
|
|
|
|
IntPtr condPtr = IntPtr.Zero;
|
|
IntPtr outPtr = IntPtr.Zero;
|
|
int handle = -1;
|
|
try
|
|
{
|
|
// Use Common SDK instance (same as NET_DVR_Init) and command aligned with Common struct family.
|
|
const uint NET_DVR_GET_FINGERPRINT_CFG = 2150;
|
|
|
|
var cond = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_INFO_COND();
|
|
cond.dwSize = (uint)Marshal.SizeOf(cond);
|
|
cond.byCardNo = new byte[32];
|
|
cond.byEnableCardReader = new byte[512];
|
|
cond.byRes1 = new byte[26];
|
|
cond.dwFingerPrintNum = 0xFFFFFFFF; // all fingerprints
|
|
cond.byFingerPrintID = 0xFF; // all finger ids
|
|
cond.byCallbackMode = 0; // sync pull mode
|
|
CopyUtf8(cardNo, cond.byCardNo);
|
|
|
|
int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
|
|
if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length)
|
|
cond.byEnableCardReader[readerNo - 1] = 1;
|
|
else if (cond.byEnableCardReader.Length > 0)
|
|
cond.byEnableCardReader[0] = 1;
|
|
|
|
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
|
|
Marshal.StructureToPtr(cond, condPtr, false);
|
|
|
|
handle = Common.CHCNetSDK.NET_DVR_StartRemoteConfig(
|
|
session.UserId,
|
|
NET_DVR_GET_FINGERPRINT_CFG,
|
|
condPtr,
|
|
(int)cond.dwSize,
|
|
null,
|
|
IntPtr.Zero);
|
|
|
|
if (handle < 0)
|
|
{
|
|
int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError());
|
|
debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FINGERPRINT_CFG) failed err=" + err;
|
|
return false;
|
|
}
|
|
|
|
var outCfg = new Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG();
|
|
outCfg.dwSize = (uint)Marshal.SizeOf(outCfg);
|
|
outCfg.byCardNo = new byte[32];
|
|
outCfg.byEnableCardReader = new byte[512];
|
|
outCfg.byRes1 = new byte[30];
|
|
outCfg.byFingerData = new byte[Common.CHCNetSDK.MAX_FINGER_PRINT_LEN];
|
|
outCfg.byRes = new byte[64];
|
|
int outSize = Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG));
|
|
outPtr = Marshal.AllocHGlobal(outSize);
|
|
|
|
int rows = 0;
|
|
int loops = 0;
|
|
while (loops++ < 3000)
|
|
{
|
|
Marshal.StructureToPtr(outCfg, outPtr, false);
|
|
int status = Common.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize);
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
|
|
{
|
|
rows++;
|
|
var row = Marshal.PtrToStructure<Common.CHCNetSDK.NET_DVR_FINGER_PRINT_CFG>(outPtr);
|
|
int len = (int)Math.Min(row.dwFingerPrintLen, (uint)(row.byFingerData?.Length ?? 0));
|
|
if (len > 0)
|
|
{
|
|
var bytes = new byte[len];
|
|
Buffer.BlockCopy(row.byFingerData, 0, bytes, 0, len);
|
|
fingerprints.Add(new FingerprintTemplateExportItem
|
|
{
|
|
fingerId = row.byFingerPrintID,
|
|
attempted = true,
|
|
present = true,
|
|
byteLength = len,
|
|
dataBase64 = Convert.ToBase64String(bytes),
|
|
error = ""
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
|
|
{
|
|
Thread.Sleep(80);
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)Common.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
|
|
{
|
|
debug = "fingerprint remote-config finished rows=" + rows;
|
|
return true;
|
|
}
|
|
|
|
int err = unchecked((int)Common.CHCNetSDK.NET_DVR_GetLastError());
|
|
debug = "NET_DVR_GetNextRemoteConfig(fingerprint) status=" + status + ", err=" + err;
|
|
return false;
|
|
}
|
|
|
|
debug = "fingerprint remote-config timed out";
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
try { if (handle >= 0) Common.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
|
|
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
|
|
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
private bool TryFetchFaceTemplateViaRemoteConfig(
|
|
DeviceSession session,
|
|
string cardNo,
|
|
out FaceTemplateExportItem faceItem,
|
|
out string debug)
|
|
{
|
|
faceItem = new FaceTemplateExportItem
|
|
{
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = ""
|
|
};
|
|
debug = "";
|
|
|
|
IntPtr condPtr = IntPtr.Zero;
|
|
IntPtr outPtr = IntPtr.Zero;
|
|
int handle = -1;
|
|
try
|
|
{
|
|
var cond = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_COND();
|
|
cond.Init();
|
|
cond.dwSize = (uint)Marshal.SizeOf(cond);
|
|
cond.dwFaceNum = 0xFFFFFFFF; // all faces for this user
|
|
cond.byFaceID = 0xFF; // all face IDs
|
|
CopyUtf8(cardNo, cond.byCardNo);
|
|
int readerNo = session.Device.FaceReaderNo > 0 ? session.Device.FaceReaderNo : 1;
|
|
if (readerNo >= 1 && readerNo <= cond.byEnableCardReader.Length)
|
|
cond.byEnableCardReader[readerNo - 1] = 1;
|
|
else if (cond.byEnableCardReader.Length > 0)
|
|
cond.byEnableCardReader[0] = 1;
|
|
|
|
condPtr = Marshal.AllocHGlobal((int)cond.dwSize);
|
|
Marshal.StructureToPtr(cond, condPtr, false);
|
|
|
|
handle = EventByDeploy.CHCNetSDK.NET_DVR_StartRemoteConfig(
|
|
session.UserId,
|
|
(uint)EventByDeploy.CHCNetSDK.NET_DVR_GET_FACE,
|
|
condPtr,
|
|
(int)cond.dwSize,
|
|
null,
|
|
IntPtr.Zero);
|
|
|
|
if (handle < 0)
|
|
{
|
|
int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError());
|
|
debug = "NET_DVR_StartRemoteConfig(NET_DVR_GET_FACE) failed err=" + err;
|
|
return false;
|
|
}
|
|
|
|
var outCfg = new EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG();
|
|
outCfg.Init();
|
|
outCfg.dwSize = (uint)Marshal.SizeOf(outCfg);
|
|
int outSize = Marshal.SizeOf(typeof(EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG));
|
|
outPtr = Marshal.AllocHGlobal(outSize);
|
|
|
|
int rows = 0;
|
|
int loops = 0;
|
|
while (loops++ < 3000)
|
|
{
|
|
Marshal.StructureToPtr(outCfg, outPtr, false);
|
|
int status = EventByDeploy.CHCNetSDK.NET_DVR_GetNextRemoteConfig(handle, outPtr, (uint)outSize);
|
|
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_SUCCESS)
|
|
{
|
|
rows++;
|
|
var row = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_FACE_PARAM_CFG>(outPtr);
|
|
int len = (int)row.dwFaceLen;
|
|
if (len > 0 && row.pFaceBuffer != IntPtr.Zero)
|
|
{
|
|
var bytes = new byte[len];
|
|
Marshal.Copy(row.pFaceBuffer, bytes, 0, len);
|
|
faceItem.present = true;
|
|
faceItem.byteLength = len;
|
|
faceItem.dataBase64 = Convert.ToBase64String(bytes);
|
|
faceItem.error = "";
|
|
// Keep reading until FINISH so SDK state is clean.
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NETX_STATUS_NEED_WAIT)
|
|
{
|
|
Thread.Sleep(80);
|
|
continue;
|
|
}
|
|
|
|
if (status == (int)EventByDeploy.CHCNetSDK.NET_SDK_GET_NEXT_STATUS.NET_SDK_GET_NEXT_STATUS_FINISH)
|
|
{
|
|
debug = "face remote-config finished rows=" + rows;
|
|
if (!faceItem.present)
|
|
faceItem.error = "face not enrolled";
|
|
return true;
|
|
}
|
|
|
|
int err = unchecked((int)EventByDeploy.CHCNetSDK.NET_DVR_GetLastError());
|
|
debug = "NET_DVR_GetNextRemoteConfig(face) status=" + status + ", err=" + err;
|
|
if (err == 17)
|
|
faceItem.error = "missing(errorCode17)";
|
|
else
|
|
faceItem.error = debug;
|
|
return false;
|
|
}
|
|
|
|
debug = "face remote-config timed out";
|
|
faceItem.error = debug;
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
try { if (handle >= 0) EventByDeploy.CHCNetSDK.NET_DVR_StopRemoteConfig(handle); } catch { /* ignore */ }
|
|
try { if (condPtr != IntPtr.Zero) Marshal.FreeHGlobal(condPtr); } catch { /* ignore */ }
|
|
try { if (outPtr != IntPtr.Zero) Marshal.FreeHGlobal(outPtr); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
private sealed class MultipartMixedPart
|
|
{
|
|
public string headersText = "";
|
|
public string contentType = "";
|
|
public string? contentDispositionName = null;
|
|
public byte[] bodyBytes = Array.Empty<byte>();
|
|
}
|
|
|
|
private static string DecodeBytesForText(byte[] bytes)
|
|
{
|
|
// Prefer UTF-8, but fall back to ISO-8859-1 to avoid losing ASCII fragments
|
|
// (e.g., JSON metadata + multipart boundaries).
|
|
try
|
|
{
|
|
return Encoding.UTF8.GetString(bytes);
|
|
}
|
|
catch
|
|
{
|
|
return Encoding.GetEncoding("iso-8859-1").GetString(bytes);
|
|
}
|
|
}
|
|
|
|
private static int? TryExtractErrorCodeAny(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return null;
|
|
|
|
// Common shapes: "errorCode": 17 or <errorCode>17</errorCode>
|
|
try
|
|
{
|
|
int idx = text.IndexOf("errorCode", StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
idx = text.IndexOf("error_code", StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
return null;
|
|
|
|
var tail = text.Substring(idx);
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < tail.Length; i++)
|
|
{
|
|
char c = tail[i];
|
|
if (char.IsDigit(c) || c == '-')
|
|
sb.Append(c);
|
|
else if (sb.Length > 0)
|
|
break;
|
|
}
|
|
|
|
if (sb.Length > 0 && int.TryParse(sb.ToString(), out var code))
|
|
return code;
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool TryExtractMultipartBoundary(byte[] responseBytes, out string boundary)
|
|
{
|
|
boundary = "";
|
|
if (responseBytes == null || responseBytes.Length == 0)
|
|
return false;
|
|
|
|
// Boundary usually lives in the ASCII preamble. We only need to look at the first chunk.
|
|
int scanLen = Math.Min(responseBytes.Length, 16 * 1024);
|
|
var head = responseBytes.Take(scanLen).ToArray();
|
|
var headText = DecodeBytesForText(head);
|
|
|
|
// Examples:
|
|
// - boundary=someBoundary
|
|
// - boundary="someBoundary"
|
|
var m = System.Text.RegularExpressions.Regex.Match(
|
|
headText,
|
|
"boundary\\s*=\\s*\"?(?<b>[^;\\s\\\"]+)\"?",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
|
|
if (!m.Success || string.IsNullOrWhiteSpace(m.Groups["b"].Value))
|
|
return false;
|
|
|
|
boundary = m.Groups["b"].Value.Trim();
|
|
return !string.IsNullOrWhiteSpace(boundary);
|
|
}
|
|
|
|
private static int IndexOfBytes(byte[] haystack, byte[] needle, int startIndex)
|
|
{
|
|
if (needle.Length == 0)
|
|
return -1;
|
|
for (int i = startIndex; i <= haystack.Length - needle.Length; i++)
|
|
{
|
|
bool ok = true;
|
|
for (int j = 0; j < needle.Length; j++)
|
|
{
|
|
if (haystack[i + j] != needle[j])
|
|
{
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (ok)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
private static bool TryParseMultipartMixed(byte[] responseBytes, out List<MultipartMixedPart> parts, out string parseError)
|
|
{
|
|
parts = new List<MultipartMixedPart>();
|
|
parseError = "";
|
|
|
|
if (responseBytes == null || responseBytes.Length == 0)
|
|
return false;
|
|
|
|
if (!TryExtractMultipartBoundary(responseBytes, out var boundary))
|
|
return false;
|
|
|
|
// Find boundary occurrences.
|
|
var boundaryMarker = Encoding.ASCII.GetBytes("--" + boundary);
|
|
int pos = 0;
|
|
var positions = new List<int>();
|
|
while (true)
|
|
{
|
|
int p = IndexOfBytes(responseBytes, boundaryMarker, pos);
|
|
if (p < 0)
|
|
break;
|
|
positions.Add(p);
|
|
pos = p + boundaryMarker.Length;
|
|
if (positions.Count > 2000) // guardrail
|
|
break;
|
|
}
|
|
|
|
if (positions.Count < 2)
|
|
return false;
|
|
|
|
for (int i = 0; i < positions.Count - 1; i++)
|
|
{
|
|
int segStart = positions[i] + boundaryMarker.Length;
|
|
int segEnd = positions[i + 1];
|
|
|
|
if (segEnd <= segStart)
|
|
continue;
|
|
|
|
var segment = new byte[segEnd - segStart];
|
|
Buffer.BlockCopy(responseBytes, segStart, segment, 0, segment.Length);
|
|
|
|
// Trim leading CRLF
|
|
int trimStart = 0;
|
|
while (trimStart < segment.Length && (segment[trimStart] == (byte)'\r' || segment[trimStart] == (byte)'\n'))
|
|
trimStart++;
|
|
if (trimStart > 0)
|
|
segment = segment.Skip(trimStart).ToArray();
|
|
|
|
// Trim trailing CRLF
|
|
int trimEnd = segment.Length;
|
|
while (trimEnd > 0 && (segment[trimEnd - 1] == (byte)'\r' || segment[trimEnd - 1] == (byte)'\n'))
|
|
trimEnd--;
|
|
if (trimEnd != segment.Length)
|
|
segment = segment.Take(trimEnd).ToArray();
|
|
|
|
if (segment.Length == 0)
|
|
continue;
|
|
|
|
// Split headers vs body: look for CRLFCRLF or LFLF.
|
|
int headerEnd = IndexOfBytes(segment, new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' }, 0);
|
|
int lfHeaderEnd = -1;
|
|
if (headerEnd < 0)
|
|
lfHeaderEnd = IndexOfBytes(segment, new byte[] { (byte)'\n', (byte)'\n' }, 0);
|
|
|
|
int splitPos = headerEnd >= 0 ? headerEnd : lfHeaderEnd;
|
|
if (splitPos < 0)
|
|
{
|
|
// No headers: treat as a raw body.
|
|
parts.Add(new MultipartMixedPart
|
|
{
|
|
headersText = "",
|
|
contentType = "",
|
|
contentDispositionName = null,
|
|
bodyBytes = segment
|
|
});
|
|
continue;
|
|
}
|
|
|
|
var headersBytes = segment.Take(splitPos).ToArray();
|
|
var bodyBytes = segment.Skip(splitPos + (headerEnd >= 0 ? 4 : 2)).ToArray();
|
|
|
|
var headersText = DecodeBytesForText(headersBytes);
|
|
|
|
// content-type
|
|
string contentType = "";
|
|
var mType = System.Text.RegularExpressions.Regex.Match(
|
|
headersText,
|
|
"Content-Type\\s*:\\s*(?<t>[^;\\r\\n]+)",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (mType.Success)
|
|
contentType = mType.Groups["t"].Value.Trim();
|
|
|
|
// content-disposition name
|
|
string? dispName = null;
|
|
var mName = System.Text.RegularExpressions.Regex.Match(
|
|
headersText,
|
|
"Content-Disposition[\\s\\S]*?name\\s*=\\s*\"?(?<n>[^\";\\r\\n]+)\"?",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (mName.Success)
|
|
dispName = mName.Groups["n"].Value.Trim();
|
|
|
|
parts.Add(new MultipartMixedPart
|
|
{
|
|
headersText = headersText,
|
|
contentType = contentType,
|
|
contentDispositionName = dispName,
|
|
bodyBytes = bodyBytes
|
|
});
|
|
}
|
|
|
|
return parts.Count > 0;
|
|
}
|
|
|
|
private static bool TryExtractJsonTextFromMultipart(byte[] responseBytes, out string jsonText, out string jsonContentType, out string parseError)
|
|
{
|
|
jsonText = "";
|
|
jsonContentType = "";
|
|
parseError = "";
|
|
|
|
jsonText = "";
|
|
jsonContentType = "";
|
|
parseError = "";
|
|
|
|
if (!TryParseMultipartMixed(responseBytes, out var parts, out parseError))
|
|
return false;
|
|
|
|
foreach (var p in parts)
|
|
{
|
|
var ct = p.contentType ?? "";
|
|
var bodyText = "";
|
|
try { bodyText = DecodeBytesForText(p.bodyBytes); } catch { /* ignore */ }
|
|
|
|
if (ct.IndexOf("application/json", StringComparison.OrdinalIgnoreCase) >= 0 && LooksLikeJson(bodyText))
|
|
{
|
|
jsonText = bodyText.Trim();
|
|
jsonContentType = ct;
|
|
return true;
|
|
}
|
|
|
|
if (LooksLikeJson(bodyText) && bodyText.IndexOf("\"errorCode\"", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
jsonText = bodyText.Trim();
|
|
jsonContentType = ct;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Fallback: first JSON-like part.
|
|
foreach (var p in parts)
|
|
{
|
|
var bodyText = DecodeBytesForText(p.bodyBytes);
|
|
if (LooksLikeJson(bodyText))
|
|
{
|
|
jsonText = bodyText.Trim();
|
|
jsonContentType = p.contentType ?? "";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool LooksLikeBase64(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s))
|
|
return false;
|
|
var t = s.Trim();
|
|
if (t.Length < 16)
|
|
return false;
|
|
for (int i = 0; i < t.Length; i++)
|
|
{
|
|
char c = t[i];
|
|
if (char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
|
|
continue;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool TryExtractBase64FromJsonText(string jsonText, IEnumerable<string> preferredKeySubstrings, out string base64, out string debug)
|
|
{
|
|
base64 = "";
|
|
debug = "";
|
|
if (string.IsNullOrWhiteSpace(jsonText))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(jsonText);
|
|
if (root == null)
|
|
return false;
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is Dictionary<string, object> nd)
|
|
stack.Push(nd);
|
|
else if (kv.Value is object[] arr)
|
|
foreach (var it in arr) if (it != null) stack.Push(it);
|
|
|
|
if (kv.Value is string s && LooksLikeBase64(s))
|
|
{
|
|
var key = kv.Key ?? "";
|
|
var ok = preferredKeySubstrings.Any(p => key.IndexOf(p, StringComparison.OrdinalIgnoreCase) >= 0);
|
|
if (ok)
|
|
{
|
|
base64 = s;
|
|
debug = "matchedKey=\"" + key + "\"";
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (cur is object[] arr2)
|
|
{
|
|
foreach (var it in arr2)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
debug = ex.Message;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool TryFetchFingerprintTemplatesViaIsapiDoc(
|
|
DeviceSession session,
|
|
string cardNo,
|
|
out List<FingerprintTemplateExportItem> fingerprints,
|
|
out string debug)
|
|
{
|
|
fingerprints = new List<FingerprintTemplateExportItem>();
|
|
debug = "";
|
|
|
|
int readerNo = session.Device.FingerPrintReaderNo > 0 ? session.Device.FingerPrintReaderNo : 1;
|
|
bool seenError17 = false;
|
|
bool seenNotSupport = false;
|
|
|
|
// Pro Series search endpoint for fingerprint export/readback.
|
|
var searchUrl = "/ISAPI/AccessControl/FingerPrintUpload?format=json";
|
|
var employeeNo = cardNo.Trim();
|
|
|
|
// Body shapes differ by firmware; try documented/compatibility variants.
|
|
// Capability dump for this device shows these fields are expected:
|
|
// employeeNo, enableCardReader, fingerPrintID, fingerType.
|
|
var bodies = new List<string>
|
|
{
|
|
"{ \"FingerPrintSearchCond\": { " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
|
|
"\"enableCardReader\": [" + readerNo + "]," +
|
|
"\"fingerPrintID\": 1," +
|
|
"\"fingerType\": \"normalFP\"" +
|
|
" } }",
|
|
|
|
"{ \"FingerPrintSearchCond\": { " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
|
|
"\"cardReaderNo\": " + readerNo + "," +
|
|
"\"fingerPrintID\": 1," +
|
|
"\"fingerType\": \"normalFP\"" +
|
|
" } }",
|
|
|
|
"{ \"FingerPrintCond\": { " +
|
|
"\"searchID\": \"1\", \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
|
|
"\"enableCardReader\": [" + readerNo + "]," +
|
|
"\"fingerPrintID\": 1," +
|
|
"\"fingerType\": \"normalFP\" " +
|
|
" } }",
|
|
|
|
"{ " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\"," +
|
|
"\"enableCardReader\": [" + readerNo + "]," +
|
|
"\"fingerPrintID\": 1," +
|
|
"\"fingerType\": \"normalFP\" " +
|
|
" }"
|
|
};
|
|
|
|
foreach (var body in bodies)
|
|
{
|
|
var fpBytes = StdXmlCallBytes(session.UserId, "POST", searchUrl, body, out var sdkErr);
|
|
if (fpBytes.Length == 0)
|
|
continue;
|
|
|
|
var decoded = DecodeBytesForText(fpBytes);
|
|
var errCode = TryExtractErrorCodeAny(decoded);
|
|
var statusSummary = ExtractIsapiStatusSummary(decoded);
|
|
debug = "FingerPrintUpload search tried; sdkErr=\"" + sdkErr + "\", " +
|
|
"status=(" + statusSummary + "), errCode=" + (errCode.HasValue ? errCode.Value.ToString() : "(null)");
|
|
|
|
if (statusSummary.IndexOf("subStatusCode=notSupport", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
seenNotSupport = true;
|
|
|
|
if (errCode.HasValue && errCode.Value == 17)
|
|
{
|
|
seenError17 = true;
|
|
fingerprints = new List<FingerprintTemplateExportItem>
|
|
{
|
|
new FingerprintTemplateExportItem
|
|
{
|
|
fingerId = 1,
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = "ISAPI fingerprint errorCode=17"
|
|
}
|
|
};
|
|
return true;
|
|
}
|
|
|
|
// Try direct JSON parse first.
|
|
if (TryParseFingerprintItemsFromIsapiResponse(fpBytes, out fingerprints, out var parseErr) && fingerprints.Count > 0)
|
|
return true;
|
|
|
|
// Try multipart: locate JSON part and parse it.
|
|
if (TryExtractJsonTextFromMultipart(fpBytes, out var jsonText, out var jsonCt, out var parseMultipartErr))
|
|
{
|
|
var jsonBytes = Encoding.UTF8.GetBytes(jsonText);
|
|
if (TryParseFingerprintItemsFromIsapiResponse(jsonBytes, out fingerprints, out var parseErr2) && fingerprints.Count > 0)
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (seenError17)
|
|
return true;
|
|
|
|
if (seenNotSupport)
|
|
debug = string.IsNullOrWhiteSpace(debug) ? "FingerPrintUpload not supported on this firmware/path" : (debug + "; notSupport");
|
|
|
|
return false;
|
|
}
|
|
|
|
private sealed class FaceLibCandidate
|
|
{
|
|
public int fdId;
|
|
public string faceLibType = "";
|
|
}
|
|
|
|
private static bool TryParseFaceLibCandidatesFromFdLibResponse(byte[] responseBytes, out List<FaceLibCandidate> candidates, out string parseError)
|
|
{
|
|
candidates = new List<FaceLibCandidate>();
|
|
parseError = "";
|
|
|
|
if (responseBytes == null || responseBytes.Length == 0)
|
|
return false;
|
|
|
|
var text = "";
|
|
try { text = DecodeBytesForText(responseBytes); } catch { /* ignore */ }
|
|
if (!LooksLikeJson(text))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(text);
|
|
if (root == null)
|
|
return false;
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
// Look for dictionaries that have both an FDID and a faceLibType.
|
|
int? fdid = null;
|
|
if (TryGetIntNullableStatic(d, "FDID", out var v1)) fdid = v1;
|
|
if (!fdid.HasValue && TryGetIntNullableStatic(d, "fdId", out var v2)) fdid = v2;
|
|
|
|
string? faceLibType = null;
|
|
if (TryGetStringStatic(d, "faceLibType", out var t1)) faceLibType = t1;
|
|
if (faceLibType == null && TryGetStringStatic(d, "faceLib", out var t2)) faceLibType = t2;
|
|
if (faceLibType == null && TryGetStringStatic(d, "libType", out var t3)) faceLibType = t3;
|
|
|
|
if (fdid.HasValue && !string.IsNullOrWhiteSpace(faceLibType))
|
|
{
|
|
candidates.Add(new FaceLibCandidate
|
|
{
|
|
fdId = fdid.Value,
|
|
faceLibType = faceLibType.Trim()
|
|
});
|
|
}
|
|
|
|
foreach (var kv in d.Values)
|
|
{
|
|
if (kv is Dictionary<string, object> nd)
|
|
stack.Push(nd);
|
|
else if (kv is object[] arr)
|
|
foreach (var it in arr) if (it != null) stack.Push(it);
|
|
}
|
|
}
|
|
else if (cur is object[] arr2)
|
|
{
|
|
foreach (var it in arr2) if (it != null) stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
parseError = ex.Message;
|
|
return false;
|
|
}
|
|
|
|
return candidates.Count > 0;
|
|
}
|
|
|
|
private static bool TryGetIntNullableStatic(Dictionary<string, object> d, string key, out int value)
|
|
{
|
|
value = 0;
|
|
if (!d.TryGetValue(key, out var v) || v == null)
|
|
return false;
|
|
if (v is int i) { value = i; return true; }
|
|
if (v is long l) { value = (int)l; return true; }
|
|
if (v is double dd) { value = (int)dd; return true; }
|
|
if (v is string s && int.TryParse(s, out var p)) { value = p; return true; }
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetStringStatic(Dictionary<string, object> d, string key, out string value)
|
|
{
|
|
value = "";
|
|
if (!d.TryGetValue(key, out var v) || v == null)
|
|
return false;
|
|
if (v is string s)
|
|
{
|
|
value = s;
|
|
return true;
|
|
}
|
|
value = v.ToString() ?? "";
|
|
return !string.IsNullOrWhiteSpace(value);
|
|
}
|
|
|
|
private bool TryFetchFaceTemplateViaIsapiDoc(
|
|
DeviceSession session,
|
|
string cardNo,
|
|
out FaceTemplateExportItem faceItem,
|
|
out string debug)
|
|
{
|
|
faceItem = new FaceTemplateExportItem
|
|
{
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = ""
|
|
};
|
|
debug = "";
|
|
bool seenError17 = false;
|
|
|
|
static bool TryFindFaceRecordPointers(string json, out string fpid, out string faceUrl)
|
|
{
|
|
fpid = "";
|
|
faceUrl = "";
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(json);
|
|
if (root == null)
|
|
return false;
|
|
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (kv.Value is Dictionary<string, object> nd)
|
|
stack.Push(nd);
|
|
else if (kv.Value is object[] arr)
|
|
foreach (var it in arr) if (it != null) stack.Push(it);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(fpid))
|
|
{
|
|
if (d.TryGetValue("FPID", out var fp) && fp != null) fpid = fp.ToString() ?? "";
|
|
else if (d.TryGetValue("fPID", out var fp2) && fp2 != null) fpid = fp2.ToString() ?? "";
|
|
}
|
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
|
{
|
|
if (d.TryGetValue("faceURL", out var fu) && fu is string s1 && !string.IsNullOrWhiteSpace(s1)) faceUrl = s1;
|
|
else if (d.TryGetValue("pictureURL", out var fu2) && fu2 is string s2 && !string.IsNullOrWhiteSpace(s2)) faceUrl = s2;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(faceUrl))
|
|
return true;
|
|
}
|
|
else if (cur is object[] arr2)
|
|
{
|
|
foreach (var it in arr2)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !string.IsNullOrWhiteSpace(faceUrl);
|
|
}
|
|
|
|
static string NormalizeFaceUrlToIsapiPath(string url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
return "";
|
|
if (url.StartsWith("/"))
|
|
return url;
|
|
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (Uri.TryCreate(url, UriKind.Absolute, out var u))
|
|
return u.PathAndQuery;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
// Discover face picture libraries.
|
|
var fdLibBytes = StdXmlCallBytes(session.UserId, "GET", "/ISAPI/Intelligent/FDLib?format=json", null, out var fdLibSdkErr);
|
|
if (fdLibBytes.Length == 0)
|
|
{
|
|
debug = "FDLib discovery returned empty: sdkErr=\"" + fdLibSdkErr + "\"";
|
|
faceItem.error = debug;
|
|
return false;
|
|
}
|
|
|
|
if (!TryParseFaceLibCandidatesFromFdLibResponse(fdLibBytes, out var libs, out var fdLibParseErr) || libs.Count == 0)
|
|
{
|
|
debug = "FDLib parse yielded no candidates: parseErr=\"" + fdLibParseErr + "\"";
|
|
faceItem.error = debug;
|
|
return false;
|
|
}
|
|
|
|
var fdSearchUrl = "/ISAPI/Intelligent/FDLib/FDSearch?format=json";
|
|
foreach (var lib in libs)
|
|
{
|
|
// Pro Series schema (12.3.2.6): root-level search fields.
|
|
// Try multiple compatible variants to avoid badJsonContent/MessageParametersLack.
|
|
var bodies = new List<string>
|
|
{
|
|
"{ " +
|
|
"\"searchID\": \"1\", " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
|
|
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
|
|
"\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"," +
|
|
"\"gender\": \"any\", " +
|
|
"\"certificateType\": \"ID\" " +
|
|
" }",
|
|
|
|
"{ " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
|
|
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
|
|
"\"FPID\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" +
|
|
" }",
|
|
|
|
"{ " +
|
|
"\"searchResultPosition\": 0, " +
|
|
"\"maxResults\": 10, " +
|
|
"\"faceLibType\": \"" + EscapeJsonStatic(lib.faceLibType) + "\"," +
|
|
"\"FDID\": \"" + EscapeJsonStatic(lib.fdId.ToString(CultureInfo.InvariantCulture)) + "\"," +
|
|
"\"employeeNo\": \"" + EscapeJsonStatic(cardNo.Trim()) + "\"" +
|
|
" }"
|
|
};
|
|
|
|
for (int bi = 0; bi < bodies.Count; bi++)
|
|
{
|
|
var body = bodies[bi];
|
|
var fdBytes = StdXmlCallBytes(session.UserId, "POST", fdSearchUrl, body, out var fdSdkErr);
|
|
if (fdBytes.Length == 0)
|
|
continue;
|
|
|
|
var decoded = DecodeBytesForText(fdBytes);
|
|
var errCode = TryExtractErrorCodeAny(decoded);
|
|
debug = "FDSearch variant#" + (bi + 1) + " libType=\"" + lib.faceLibType + "\" FDID=" + lib.fdId +
|
|
" sdkErr=\"" + fdSdkErr + "\" " + ExtractIsapiStatusSummary(decoded);
|
|
if (errCode.HasValue && errCode.Value == 17)
|
|
{
|
|
seenError17 = true;
|
|
continue;
|
|
}
|
|
|
|
if (!TryFindFaceRecordPointers(decoded, out var fpid, out var faceUrl) || string.IsNullOrWhiteSpace(faceUrl))
|
|
continue;
|
|
|
|
var facePath = NormalizeFaceUrlToIsapiPath(faceUrl);
|
|
if (string.IsNullOrWhiteSpace(facePath))
|
|
continue;
|
|
|
|
var picBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out var picSdkErr);
|
|
if (picBytes.Length > 0)
|
|
{
|
|
faceItem.present = true;
|
|
faceItem.byteLength = picBytes.Length;
|
|
faceItem.dataBase64 = Convert.ToBase64String(picBytes);
|
|
faceItem.error = "ok(faceURL, FPID=" + (string.IsNullOrWhiteSpace(fpid) ? "-" : fpid) + ")";
|
|
return true;
|
|
}
|
|
|
|
debug = debug + ", faceURLGetErr=\"" + picSdkErr + "\", faceURL=\"" + facePath + "\"";
|
|
}
|
|
}
|
|
|
|
faceItem.present = false;
|
|
faceItem.byteLength = 0;
|
|
faceItem.dataBase64 = "";
|
|
faceItem.error = seenError17 ? "missing(errorCode17)" : "face not found (FDSearch by employeeNo)";
|
|
return false;
|
|
}
|
|
|
|
public bool TryExportAllUsersTemplatesToIsapiFile(
|
|
string deviceId,
|
|
string? outDir,
|
|
int pageSize,
|
|
int maxUsers,
|
|
CancellationToken cancellationToken,
|
|
out string writtenJsonPath,
|
|
out int discoveredUsers,
|
|
out string error)
|
|
{
|
|
writtenJsonPath = "";
|
|
discoveredUsers = 0;
|
|
error = "";
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
if (pageSize <= 0) pageSize = 30;
|
|
if (maxUsers <= 0) maxUsers = 10000;
|
|
|
|
var dir = string.IsNullOrWhiteSpace(outDir) ? _config.LogDirectory : outDir.Trim();
|
|
Directory.CreateDirectory(dir);
|
|
|
|
writtenJsonPath = Path.Combine(dir, BuildAllUsersTemplatesExportFileName(deviceId).Replace(".json", "_isapi.json"));
|
|
|
|
var faceLogPath = Path.Combine(dir, "face_templates_log.txt");
|
|
var fingerprintLogPath = Path.Combine(dir, "fingerprint_templates_log.txt");
|
|
var templateInternalRoot = Path.Combine(_config.LogDirectory, "internal_logs", "template_internal_logs");
|
|
var templateFaceInternalDir = Path.Combine(templateInternalRoot, "face");
|
|
var templateFingerInternalDir = Path.Combine(templateInternalRoot, "finger");
|
|
Directory.CreateDirectory(templateFaceInternalDir);
|
|
Directory.CreateDirectory(templateFingerInternalDir);
|
|
var localDate = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
|
var templateFaceStatusLogPath = Path.Combine(templateFaceInternalDir, "face_templates_fetch_" + localDate + ".txt");
|
|
var templateFingerStatusLogPath = Path.Combine(templateFingerInternalDir, "finger_templates_fetch_" + localDate + ".txt");
|
|
|
|
var perUserDir = Path.Combine(dir, "per_user");
|
|
Directory.CreateDirectory(perUserDir);
|
|
|
|
var root = new AllUsersTemplateExportPayload
|
|
{
|
|
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
|
deviceId = deviceId.Trim(),
|
|
userListSource = "STDXMLConfig: /ISAPI/AccessControl/UserInfo/Search"
|
|
};
|
|
|
|
var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken);
|
|
root.listFetchError = listErr;
|
|
discoveredUsers = cardNos.Count;
|
|
|
|
// DB population: attendance_machine_user (device → DB users list)
|
|
int upsertedUsers = 0;
|
|
int upsertFailed = 0;
|
|
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
|
|
{
|
|
foreach (var eno in cardNos)
|
|
{
|
|
var serial = (eno ?? "").Trim();
|
|
if (serial.Length == 0)
|
|
continue;
|
|
if (_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), serial, "", "hikvision-service", out var upErr))
|
|
upsertedUsers++;
|
|
else
|
|
upsertFailed++;
|
|
}
|
|
_logger.JobInfo("template_fetch", "DB attendance_machine_user upsert: machine_id=" + deviceId.Trim() +
|
|
" discoveredUsers=" + discoveredUsers + " upserted=" + upsertedUsers + " failed=" + upsertFailed);
|
|
}
|
|
|
|
using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
using var templateFaceStatusSw = new StreamWriter(templateFaceStatusLogPath, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
using var templateFingerStatusSw = new StreamWriter(templateFingerStatusLogPath, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export face templates");
|
|
fpSw.WriteLine(DateTime.UtcNow.ToString("o") + " ISAPI export fingerprint templates");
|
|
if (new FileInfo(templateFaceStatusLogPath).Length == 0)
|
|
{
|
|
templateFaceStatusSw.WriteLine("========================================");
|
|
templateFaceStatusSw.WriteLine("FACE TEMPLATE FETCH DETAILS");
|
|
templateFaceStatusSw.WriteLine("Device IP: " + (session.Device.Ip ?? ""));
|
|
templateFaceStatusSw.WriteLine("Date: " + localDate);
|
|
templateFaceStatusSw.WriteLine("========================================");
|
|
}
|
|
if (new FileInfo(templateFingerStatusLogPath).Length == 0)
|
|
{
|
|
templateFingerStatusSw.WriteLine("========================================");
|
|
templateFingerStatusSw.WriteLine("FINGERPRINT TEMPLATE FETCH DETAILS");
|
|
templateFingerStatusSw.WriteLine("Device IP: " + (session.Device.Ip ?? ""));
|
|
templateFingerStatusSw.WriteLine("Date: " + localDate);
|
|
templateFingerStatusSw.WriteLine("========================================");
|
|
}
|
|
|
|
_logger.Info("ExportAllTemplates ISAPI: device=" + deviceId + ", discoveredUsers=" + cardNos.Count +
|
|
", pageSize=" + pageSize + ", maxUsers=" + maxUsers + ", userListError=" + (string.IsNullOrEmpty(listErr) ? "(none)" : listErr));
|
|
|
|
var faceFetched = new List<string>();
|
|
var faceNotFetched = new List<string>();
|
|
var fingerFetched = new List<string>();
|
|
var fingerNotFetched = new List<string>();
|
|
|
|
foreach (var cardNo in cardNos)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
if (string.IsNullOrWhiteSpace(cardNo))
|
|
continue;
|
|
|
|
var userPayload = new UserTemplateExportPayload
|
|
{
|
|
exportedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
|
deviceId = deviceId.Trim(),
|
|
cardNo = cardNo.Trim(),
|
|
sdkImplementationNote =
|
|
"Pro Series ISAPI flow via NET_DVR_STDXMLConfig: capability-first, user/card search, fingerprint family (FingerPrintCfg/FingerPrintDownload/FingerPrintProgress), and face FDLib/FDSearch. Old /Face/{id}/picture and /FingerPrint/{id}/data are not used as primary."
|
|
};
|
|
|
|
// Card flow probe for this employeeNo/person key.
|
|
if (TrySearchCardInfoByEmployeeNoIsapi(session.UserId, cardNo.Trim(), out var cardSearchRaw, out var cardSearchErr))
|
|
{
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
|
" cardInfoSearch status=(" + ExtractIsapiStatusSummary(cardSearchRaw) + ")" +
|
|
" sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr) +
|
|
" raw_snip=\"" + ToOneLineSnippet(cardSearchRaw) + "\"");
|
|
}
|
|
else
|
|
{
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
|
" cardInfoSearch failed sdkErr=" + (string.IsNullOrEmpty(cardSearchErr) ? "-" : cardSearchErr));
|
|
}
|
|
|
|
// Face flow: capability-first, then FDLib/FDSearch.
|
|
var fdCapRaw = ProbeCapabilityAndLog(session.UserId, "/ISAPI/Intelligent/FDLib/capabilities?format=json", "face.FDLib", faceSw);
|
|
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw);
|
|
bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
userPayload.faceReadbackSupported = !fdSearchDisabledByCap;
|
|
|
|
var faceItemDoc = new FaceTemplateExportItem();
|
|
var faceDocDebug = "";
|
|
if (fdSearchDisabledByCap)
|
|
{
|
|
// Capability explicitly reports FDSearch unsupported on this firmware.
|
|
userPayload.face = new FaceTemplateExportItem
|
|
{
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = "FDSearch unsupported by capability (isSuportFDSearch=false); enrolled face readback not available via FDSearch on this device"
|
|
};
|
|
}
|
|
else
|
|
{
|
|
TryFetchFaceTemplateViaIsapiDoc(session, cardNo, out faceItemDoc, out faceDocDebug);
|
|
userPayload.face = faceItemDoc;
|
|
}
|
|
if (!userPayload.face.present)
|
|
{
|
|
userPayload.face = new FaceTemplateExportItem
|
|
{
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = string.IsNullOrWhiteSpace(faceDocDebug)
|
|
? "Face export/readback not confirmed by Pro Series flow on this device. Capture/add may be supported; enrolled readback returned no data."
|
|
: faceDocDebug
|
|
};
|
|
if (fdSearchDisabledByCap)
|
|
userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false";
|
|
}
|
|
|
|
// Fallback: if person export/search returns a face/picture URL, download it.
|
|
if (!userPayload.face.present &&
|
|
TrySearchUserInfoByEmployeeNo(session, cardNo.Trim(), out var userInfoRaw, out var userInfoErr) &&
|
|
TryExtractFaceUrlFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userFaceUrl))
|
|
{
|
|
// Best-effort: enrich DB user name if present in UserInfo/Search payload.
|
|
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null &&
|
|
TryExtractNameFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userName))
|
|
{
|
|
_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), cardNo.Trim(), userName ?? "", "hikvision-service", out _);
|
|
}
|
|
|
|
var facePath = userFaceUrl;
|
|
if (facePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
facePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (Uri.TryCreate(facePath, UriKind.Absolute, out var u))
|
|
facePath = u.PathAndQuery;
|
|
}
|
|
|
|
byte[] userFaceBytes;
|
|
string userFaceErr;
|
|
bool faceDownloaded = _config.UseIsapiHttpForUserInfo
|
|
? TryDownloadFaceImageViaIsapiHttp(session, facePath, out userFaceBytes, out userFaceErr)
|
|
: (userFaceBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out userFaceErr)).Length > 0;
|
|
|
|
if (faceDownloaded && userFaceBytes.Length > 0)
|
|
{
|
|
userPayload.face = new FaceTemplateExportItem
|
|
{
|
|
attempted = true,
|
|
present = true,
|
|
byteLength = userFaceBytes.Length,
|
|
dataBase64 = Convert.ToBase64String(userFaceBytes),
|
|
error = ""
|
|
};
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
|
" faceSource=userInfoUrlFallback faceUrl=\"" + facePath + "\" len=" + userFaceBytes.Length);
|
|
}
|
|
else
|
|
{
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
|
" faceSource=userInfoUrlFallbackFailed faceUrl=\"" + facePath + "\" err=" +
|
|
(string.IsNullOrEmpty(userFaceErr) ? "-" : userFaceErr));
|
|
}
|
|
}
|
|
|
|
if (!userPayload.face.present)
|
|
{
|
|
// Make missing template errors explicit in the face log.
|
|
var err = userPayload.face.error ?? "";
|
|
if (err.IndexOf("17", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " missing(errorCode17)=" + err);
|
|
else
|
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + " faceError=" + err);
|
|
}
|
|
|
|
templateFaceStatusSw.WriteLine(DateTime.UtcNow.ToString("o") +
|
|
" machine_name=" + deviceId +
|
|
" machine_ip=" + (session.Device.Ip ?? "") +
|
|
" emp_no=" + cardNo.Trim() +
|
|
" template=" + (userPayload.face.present ? "fetched" : "not_fetched"));
|
|
if (_config.EnableDbIntegration &&
|
|
_config.EnableTemplateDbPersistence &&
|
|
_config.EnableTemplateDeviceToDbSync &&
|
|
userPayload.face.present &&
|
|
!string.IsNullOrWhiteSpace(userPayload.face.dataBase64) &&
|
|
_attendanceMachineFaceTemplateRepository != null)
|
|
{
|
|
try
|
|
{
|
|
var bytes = Convert.FromBase64String(userPayload.face.dataBase64);
|
|
if (_attendanceMachineFaceTemplateRepository.UpsertFaceTemplate(cardNo.Trim(), bytes, DateTime.UtcNow, true, deviceId.Trim(), session.Device.Ip, out var createdNew, out var dbErr))
|
|
{
|
|
if (createdNew)
|
|
{
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> DB = FACE TEMPLATE INSERTED SUCCESSFULLY");
|
|
_logger.Totals.TemplatesSaved++;
|
|
_logger.Biz(BizChannel.Template,
|
|
"Employee " + cardNo.Trim(),
|
|
"",
|
|
"Face Template",
|
|
"",
|
|
"Saved successfully.",
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Template);
|
|
}
|
|
else
|
|
{
|
|
// Already in DB — do not rewrite and do not spam business logs.
|
|
_logger.Diag("template_fetch",
|
|
"face template already in DB — skipped employee=" + cardNo.Trim() +
|
|
" device=" + deviceId);
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> DB = FACE TEMPLATE SKIPPED reason=\"already present\"");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.OpsError(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> DB = FACE TEMPLATE FAILED reason=\"" + dbErr + "\"");
|
|
_logger.Totals.TemplatesFailed++;
|
|
_logger.Biz(BizChannel.Template,
|
|
"Employee " + cardNo.Trim(),
|
|
"",
|
|
"Face Template",
|
|
"",
|
|
"Save failed.",
|
|
"",
|
|
"Reason :",
|
|
"",
|
|
dbErr ?? "Unknown error.",
|
|
"");
|
|
_logger.BizSeparator(BizChannel.Template);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.OpsError(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> DB = FACE TEMPLATE FAILED reason=\"" + ex.Message + "\"");
|
|
_logger.Totals.TemplatesFailed++;
|
|
}
|
|
}
|
|
else if (!userPayload.face.present)
|
|
{
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> DB = FACE TEMPLATE SKIPPED reason=\"no face on device\"");
|
|
}
|
|
if (userPayload.face.present) faceFetched.Add(cardNo.Trim()); else faceNotFetched.Add(cardNo.Trim());
|
|
|
|
// Fingerprint flow: capability-first, then FingerPrintDownload family.
|
|
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/FingerPrintCfg/capabilities?format=json", "fingerprint.FingerPrintCfg", fpSw);
|
|
var fpItemsDoc = new List<FingerprintTemplateExportItem>();
|
|
var fpDocDebug = "";
|
|
bool fpDocOk = TryFetchFingerprintTemplatesViaRemoteConfig(session, cardNo, out fpItemsDoc, out fpDocDebug);
|
|
if (!fpDocOk || fpItemsDoc.Count == 0)
|
|
{
|
|
// Fallback to Pro-Series ISAPI fingerprint search/upload endpoint family.
|
|
fpDocOk = TryFetchFingerprintTemplatesViaIsapiDoc(session, cardNo, out fpItemsDoc, out fpDocDebug);
|
|
}
|
|
if (fpDocOk && fpItemsDoc.Count > 0)
|
|
{
|
|
userPayload.fingerprints = fpItemsDoc;
|
|
}
|
|
else
|
|
{
|
|
userPayload.fingerprints = new List<FingerprintTemplateExportItem>
|
|
{
|
|
new FingerprintTemplateExportItem
|
|
{
|
|
fingerId = 1,
|
|
attempted = true,
|
|
present = false,
|
|
byteLength = 0,
|
|
dataBase64 = "",
|
|
error = string.IsNullOrWhiteSpace(fpDocDebug)
|
|
? "Fingerprint export/readback not confirmed by Pro Series ISAPI flow on this device. Management APIs may be supported."
|
|
: fpDocDebug
|
|
}
|
|
};
|
|
}
|
|
|
|
foreach (var fp in userPayload.fingerprints)
|
|
{
|
|
fpSw.WriteLine(DateTime.UtcNow.ToString("o") +
|
|
" device=" + deviceId +
|
|
" cardNo=" + cardNo +
|
|
" fingerId=" + fp.fingerId +
|
|
" present=" + fp.present +
|
|
" len=" + fp.byteLength +
|
|
" error=" + (string.IsNullOrEmpty(fp.error) ? "-" : fp.error));
|
|
}
|
|
|
|
bool anyFingerprintFetched = userPayload.fingerprints.Any(x => x.present);
|
|
if (anyFingerprintFetched)
|
|
{
|
|
foreach (var fp in userPayload.fingerprints.Where(x => x.present))
|
|
{
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> FILE = FINGERPRINT TEMPLATE INSERTED SUCCESSFULLY fingerId=" + fp.fingerId);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.Ops(OpsMarkers.TemplateDeviceToDb,
|
|
cardNo.Trim() + " -> FILE = FINGERPRINT TEMPLATE SKIPPED reason=\"no fingerprint on device\"");
|
|
}
|
|
templateFingerStatusSw.WriteLine(DateTime.UtcNow.ToString("o") +
|
|
" machine_name=" + deviceId +
|
|
" machine_ip=" + (session.Device.Ip ?? "") +
|
|
" emp_no=" + cardNo.Trim() +
|
|
" template=" + (anyFingerprintFetched ? "fetched" : "not_fetched"));
|
|
if (anyFingerprintFetched) fingerFetched.Add(cardNo.Trim()); else fingerNotFetched.Add(cardNo.Trim());
|
|
|
|
root.users.Add(userPayload);
|
|
|
|
// Per-user JSON file as requested.
|
|
var perUserPath = Path.Combine(perUserDir, "user_" + cardNo.Trim() + "_templates.json");
|
|
var perJson = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(userPayload);
|
|
File.WriteAllText(perUserPath, perJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
}
|
|
|
|
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(root);
|
|
File.WriteAllText(writtenJsonPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
|
_logger.Info("ExportAllTemplates ISAPI: wrote combined json path=" + writtenJsonPath + ", users=" + root.users.Count);
|
|
|
|
if (faceFetched.Count == 0)
|
|
templateFaceStatusSw.WriteLine("No face templates to fetch for this device.");
|
|
if (fingerFetched.Count == 0)
|
|
templateFingerStatusSw.WriteLine("No fingerprint templates to fetch for this device.");
|
|
|
|
_logger.JobInfo("template_fetch", "========================================");
|
|
_logger.JobInfo("template_fetch", "TEMPLATE FETCH SUMMARY");
|
|
_logger.JobInfo("template_fetch", "========================================");
|
|
_logger.JobInfo("template_fetch", "Device IP: " + (session.Device.Ip ?? ""));
|
|
_logger.JobInfo("template_fetch", "Device ID: " + deviceId);
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "=== FACE TEMPLATES ===");
|
|
_logger.JobInfo("template_fetch", "Total users processed: " + root.users.Count);
|
|
_logger.JobInfo("template_fetch", "Users with face template fetched: " + faceFetched.Count);
|
|
_logger.JobInfo("template_fetch", "Users with NO face template: " + faceNotFetched.Count);
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "User list with face templates:");
|
|
if (faceFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
|
|
foreach (var emp in faceFetched) _logger.JobInfo("template_fetch", " - " + emp + " (fetched)");
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "User list with NO face templates:");
|
|
if (faceNotFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
|
|
foreach (var emp in faceNotFetched) _logger.JobInfo("template_fetch", " - " + emp);
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "=== FINGERPRINT TEMPLATES ===");
|
|
_logger.JobInfo("template_fetch", "Total users processed: " + root.users.Count);
|
|
_logger.JobInfo("template_fetch", "Users with fingerprint template fetched: " + fingerFetched.Count);
|
|
_logger.JobInfo("template_fetch", "Users with NO fingerprint template: " + fingerNotFetched.Count);
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "User list with fingerprint templates:");
|
|
if (fingerFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
|
|
foreach (var emp in fingerFetched) _logger.JobInfo("template_fetch", " - " + emp + " (fetched)");
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "User list with NO fingerprint templates:");
|
|
if (fingerNotFetched.Count == 0) _logger.JobInfo("template_fetch", " - (none)");
|
|
foreach (var emp in fingerNotFetched) _logger.JobInfo("template_fetch", " - " + emp);
|
|
_logger.JobInfo("template_fetch", "");
|
|
_logger.JobInfo("template_fetch", "========================================");
|
|
return true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
error = "cancelled";
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
_logger.Error("TryExportAllUsersTemplatesToIsapiFile failed", ex);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string ParseStdXmlResponseStatus(string responseJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(responseJson))
|
|
return "(empty)";
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? obj = ser.DeserializeObject(responseJson);
|
|
if (obj == null)
|
|
return "(unparsed)";
|
|
|
|
if (TryFindString(obj, new[] { "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out var s))
|
|
return s;
|
|
|
|
if (TryFindString(obj, new[] { "ResponseStatus" }, out var s2))
|
|
return s2;
|
|
|
|
if (TryFindInt(obj, new[] { "statusCode", "responseStatusCode" }, out var i))
|
|
return "code=" + i;
|
|
|
|
return "(parsed:no known status keys)";
|
|
}
|
|
catch
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(
|
|
responseJson,
|
|
"\"responseStatus(Strg|Str|String)\"\\s*:\\s*\"(?<v>[^\"]*)\"",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (m.Success)
|
|
return m.Groups["v"].Value;
|
|
return "(unparsed)";
|
|
}
|
|
}
|
|
|
|
private static bool TryFindString(object obj, IEnumerable<string> keys, out string value)
|
|
{
|
|
value = "";
|
|
var stack = new Stack<object>();
|
|
stack.Push(obj);
|
|
var keySet = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (keySet.Contains(kv.Key) && kv.Value is string sv)
|
|
{
|
|
value = sv;
|
|
return true;
|
|
}
|
|
|
|
if (kv.Value != null)
|
|
stack.Push(kv.Value);
|
|
}
|
|
}
|
|
else if (cur is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool TryFindInt(object obj, IEnumerable<string> keys, out int value)
|
|
{
|
|
value = 0;
|
|
var stack = new Stack<object>();
|
|
stack.Push(obj);
|
|
var keySet = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> d)
|
|
{
|
|
foreach (var kv in d)
|
|
{
|
|
if (keySet.Contains(kv.Key))
|
|
{
|
|
if (kv.Value is int i)
|
|
{
|
|
value = i;
|
|
return true;
|
|
}
|
|
if (kv.Value is long l)
|
|
{
|
|
value = (int)l;
|
|
return true;
|
|
}
|
|
if (kv.Value is double dd)
|
|
{
|
|
value = (int)dd;
|
|
return true;
|
|
}
|
|
if (kv.Value is decimal dec)
|
|
{
|
|
value = (int)dec;
|
|
return true;
|
|
}
|
|
if (kv.Value is string s && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
|
{
|
|
value = p;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (kv.Value != null)
|
|
stack.Push(kv.Value);
|
|
}
|
|
}
|
|
else if (cur is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static List<Dictionary<string, object>> ExtractAcsEventInfoList(string responseJson)
|
|
{
|
|
var result = new List<Dictionary<string, object>>();
|
|
if (string.IsNullOrWhiteSpace(responseJson))
|
|
return result;
|
|
|
|
try
|
|
{
|
|
var ser = new JavaScriptSerializer();
|
|
object? root = ser.DeserializeObject(responseJson);
|
|
if (root == null)
|
|
return result;
|
|
|
|
// Typical structure: { "AcsEvent": { "InfoList": [ ... ] } }
|
|
if (root is Dictionary<string, object> d && d.TryGetValue("AcsEvent", out var acsObj) && acsObj is Dictionary<string, object> acsDict)
|
|
{
|
|
if (acsDict.TryGetValue("InfoList", out var infoListObj) && infoListObj is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
{
|
|
if (it is Dictionary<string, object> itemDict)
|
|
result.Add(itemDict);
|
|
}
|
|
|
|
if (result.Count > 0)
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Recursive best-effort fallback.
|
|
var stack = new Stack<object>();
|
|
stack.Push(root);
|
|
while (stack.Count > 0 && result.Count == 0)
|
|
{
|
|
var cur = stack.Pop();
|
|
if (cur is Dictionary<string, object> cd)
|
|
{
|
|
foreach (var kv in cd)
|
|
{
|
|
if (string.Equals(kv.Key, "InfoList", StringComparison.OrdinalIgnoreCase) && kv.Value is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
{
|
|
if (it is Dictionary<string, object> itemDict)
|
|
result.Add(itemDict);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (kv.Value != null)
|
|
stack.Push(kv.Value);
|
|
}
|
|
}
|
|
else if (cur is object[] arr)
|
|
{
|
|
foreach (var it in arr)
|
|
if (it != null)
|
|
stack.Push(it);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort only; caller logs raw response.
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool TryBuildAttendanceFromStdAcsInfo(
|
|
HikvisionAttendanceWindowsService.DeviceConfig device,
|
|
Dictionary<string, object> info,
|
|
uint rawMajor,
|
|
uint rawMinor,
|
|
string eventName,
|
|
string eventType,
|
|
bool isSuccessByMinorRule,
|
|
out AttendanceEvent attendanceEvent)
|
|
{
|
|
attendanceEvent = null!;
|
|
|
|
// Required identity for attendance_log: employeeNoString only (never cardNo).
|
|
string? empStr = TryGetString(info, "employeeNoString", "employeeNo", "employeeNoStr");
|
|
if (string.IsNullOrWhiteSpace(empStr))
|
|
return false; // system / non-attendance event (major/minor only)
|
|
|
|
empStr = empStr.Trim();
|
|
if (!int.TryParse(empStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedEmp))
|
|
return false;
|
|
|
|
int? employeeNo = parsedEmp;
|
|
string? userIdentifier = empStr;
|
|
string? employeeNoString = empStr;
|
|
|
|
// Optional fields: card/door/reader vary by device & config (card is logged in files only, not attendance_log).
|
|
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: preserve Hikvision device/site wall-clock (never store UTC-shifted checktime).
|
|
DateTime ts;
|
|
string deviceTimestampRaw = "";
|
|
string deviceTimestampOffset = "";
|
|
if (!TryExtractStdAcsDateTime(info, out var parsedTs, out deviceTimestampRaw, out deviceTimestampOffset))
|
|
ts = DateTime.Now;
|
|
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,
|
|
employeeNoString,
|
|
userIdentifier,
|
|
string.IsNullOrWhiteSpace(cardNo) ? null : cardNo,
|
|
doorNo,
|
|
readerNo,
|
|
method,
|
|
eventName,
|
|
eventType,
|
|
"Historical",
|
|
isSuccess,
|
|
rawMajor,
|
|
rawMinor,
|
|
historySerialNo: 0)
|
|
{
|
|
DeviceTimestampRaw = deviceTimestampRaw,
|
|
DeviceTimestampOffset = deviceTimestampOffset
|
|
};
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string? TryGetString(Dictionary<string, object> d, params string[] keys)
|
|
{
|
|
foreach (var k in keys)
|
|
{
|
|
if (!d.TryGetValue(k, out var v) || v == null)
|
|
continue;
|
|
return v is string s ? s : v.ToString();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static int TryGetInt(Dictionary<string, object> d, string key, int defaultValue)
|
|
{
|
|
if (!d.TryGetValue(key, out var v) || v == null)
|
|
return defaultValue;
|
|
if (v is int i) return i;
|
|
if (v is long l) return (int)l;
|
|
if (v is double dd) return (int)dd;
|
|
if (v is string s && int.TryParse(s, out var p)) return p;
|
|
return defaultValue;
|
|
}
|
|
|
|
private static int? TryGetIntNullable(Dictionary<string, object> d, string key)
|
|
{
|
|
if (!d.TryGetValue(key, out var v) || v == null)
|
|
return null;
|
|
if (v is int i) return i;
|
|
if (v is long l) return (int)l;
|
|
if (v is double dd) return (int)dd;
|
|
if (v is string s && int.TryParse(s, out var p)) return p;
|
|
return null;
|
|
}
|
|
|
|
private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt) =>
|
|
TryExtractStdAcsDateTime(info, out dt, out _);
|
|
|
|
private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt, out string rawDeviceTimestamp) =>
|
|
TryExtractStdAcsDateTime(info, out dt, out rawDeviceTimestamp, out _);
|
|
|
|
private static bool TryExtractStdAcsDateTime(
|
|
Dictionary<string, object> info,
|
|
out DateTime dt,
|
|
out string rawDeviceTimestamp,
|
|
out string offsetText)
|
|
{
|
|
dt = default;
|
|
rawDeviceTimestamp = "";
|
|
offsetText = "";
|
|
|
|
string[] candidateKeys =
|
|
{
|
|
"statusTime",
|
|
"verifyTime",
|
|
"attendanceTime",
|
|
"eventTime",
|
|
"time",
|
|
"statusTimeString",
|
|
"verifyTimeString"
|
|
};
|
|
|
|
foreach (var key in candidateKeys)
|
|
{
|
|
if (info.TryGetValue(key, out var v) && v != null)
|
|
{
|
|
rawDeviceTimestamp = FormatRawDeviceTimestamp(v);
|
|
if (TryParseStdAcsDateTimeValue(v, out dt, out offsetText))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach (var kv in info)
|
|
{
|
|
if (kv.Key != null && kv.Key.IndexOf("time", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
rawDeviceTimestamp = FormatRawDeviceTimestamp(kv.Value);
|
|
if (TryParseStdAcsDateTimeValue(kv.Value, out dt, out offsetText))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
rawDeviceTimestamp = "";
|
|
offsetText = "";
|
|
return false;
|
|
}
|
|
|
|
private static string FormatRawDeviceTimestamp(object? value)
|
|
{
|
|
if (value == null)
|
|
return "";
|
|
if (value is DateTime d)
|
|
return d.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
|
|
if (value is DateTimeOffset dto)
|
|
return dto.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
|
|
return Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? "";
|
|
}
|
|
|
|
private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt) =>
|
|
TryParseStdAcsDateTimeValue(value, out dt, out _);
|
|
|
|
private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt, out string offsetText)
|
|
{
|
|
dt = default;
|
|
offsetText = "";
|
|
if (value == null)
|
|
return false;
|
|
|
|
if (value is DateTime d)
|
|
{
|
|
// Keep wall-clock components only — never ToLocalTime/ToUniversalTime for attendance.
|
|
dt = new DateTime(d.Year, d.Month, d.Day, d.Hour, d.Minute, d.Second, DateTimeKind.Unspecified);
|
|
offsetText = d.Kind == DateTimeKind.Utc ? "00:00:00" : "";
|
|
return true;
|
|
}
|
|
|
|
if (value is long l)
|
|
{
|
|
try
|
|
{
|
|
// Epoch values are instants; take UTC clock face as wall-clock digits (no host TZ shift).
|
|
DateTime utc = l > 10_000_000_000L
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime
|
|
: DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime;
|
|
dt = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second,
|
|
DateTimeKind.Unspecified);
|
|
offsetText = "00:00:00";
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (value is int i)
|
|
return TryParseStdAcsDateTimeValue((long)i, out dt, out offsetText);
|
|
|
|
string s = value is string ss ? ss : value.ToString() ?? "";
|
|
if (string.IsNullOrWhiteSpace(s))
|
|
return false;
|
|
|
|
s = s.Trim();
|
|
|
|
// Hikvision ISO8601, e.g. 2026-09-10T17:43:02Z or ...+05:00.
|
|
// Attendance must keep DEVICE WALL CLOCK digits — never ToLocalTime/ToUniversalTime.
|
|
if (DateTimeOffset.TryParse(
|
|
s,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.RoundtripKind,
|
|
out var dto))
|
|
{
|
|
dt = new DateTime(dto.Year, dto.Month, dto.Day, dto.Hour, dto.Minute, dto.Second,
|
|
DateTimeKind.Unspecified);
|
|
offsetText = FormatOffset(dto.Offset);
|
|
return true;
|
|
}
|
|
|
|
string[] formats =
|
|
{
|
|
"yyyy-MM-dd'T'HH:mm:ss",
|
|
"yyyy-MM-dd'T'HH:mm:ss.FFF",
|
|
"yyyy-MM-dd HH:mm:ss",
|
|
"yyyy-MM-dd"
|
|
};
|
|
|
|
if (DateTime.TryParseExact(
|
|
s,
|
|
formats,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out dt))
|
|
{
|
|
dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second,
|
|
DateTimeKind.Unspecified);
|
|
offsetText = "";
|
|
return true;
|
|
}
|
|
|
|
if (DateTime.TryParse(
|
|
s,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeLocal,
|
|
out dt))
|
|
{
|
|
dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second,
|
|
DateTimeKind.Unspecified);
|
|
offsetText = "";
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string FormatOffset(TimeSpan offset)
|
|
{
|
|
var sign = offset < TimeSpan.Zero ? "-" : "";
|
|
var abs = offset.Duration();
|
|
return sign + abs.Hours.ToString("00", CultureInfo.InvariantCulture) + ":" +
|
|
abs.Minutes.ToString("00", CultureInfo.InvariantCulture) + ":" +
|
|
abs.Seconds.ToString("00", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static string TruncateForLog(string s, int maxChars)
|
|
{
|
|
if (string.IsNullOrEmpty(s))
|
|
return s ?? "";
|
|
if (s.Length <= maxChars)
|
|
return s;
|
|
return s.Substring(0, maxChars) + "...[truncated " + (s.Length - maxChars) + " chars]";
|
|
}
|
|
|
|
private static CHCNetSDK.NET_DVR_TIME ToDvrTime(DateTime local)
|
|
{
|
|
return new CHCNetSDK.NET_DVR_TIME
|
|
{
|
|
dwYear = local.Year,
|
|
dwMonth = local.Month,
|
|
dwDay = local.Day,
|
|
dwHour = local.Hour,
|
|
dwMinute = local.Minute,
|
|
dwSecond = local.Second
|
|
};
|
|
}
|
|
|
|
private static void PrepareAcsEventCfgPointer(IntPtr cfgPtr, int cfgSize)
|
|
{
|
|
var cfg = new CHCNetSDK.NET_DVR_ACS_EVENT_CFG();
|
|
cfg.sNetUser = new byte[CHCNetSDK.MAX_NAMELEN];
|
|
cfg.struRemoteHostAddr.Init();
|
|
var d = new CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL();
|
|
d.dwSize = (uint)Marshal.SizeOf(typeof(CHCNetSDK.NET_DVR_ACS_EVENT_DETAIL));
|
|
d.byCardNo = new byte[CHCNetSDK.ACS_CARD_NO_LEN];
|
|
d.byMACAddr = new byte[CHCNetSDK.MACADDR_LEN];
|
|
d.byRe2 = new byte[2];
|
|
d.byEmployeeNo = new byte[CHCNetSDK.NET_SDK_EMPLOYEE_NO_LEN];
|
|
d.byRes = new byte[64];
|
|
cfg.struAcsEventInfo = d;
|
|
cfg.byRes = new byte[61];
|
|
cfg.dwSize = (uint)cfgSize;
|
|
cfg.dwPicDataLen = 0;
|
|
cfg.pPicData = IntPtr.Zero;
|
|
Marshal.StructureToPtr(cfg, cfgPtr, false);
|
|
}
|
|
|
|
private bool ControlDoor(string deviceId, uint action, out string error)
|
|
{
|
|
error = "";
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
error = "deviceId is required";
|
|
return false;
|
|
}
|
|
|
|
var session = FindSession(deviceId);
|
|
if (session == null)
|
|
{
|
|
error = "device not logged in: " + deviceId;
|
|
return false;
|
|
}
|
|
|
|
int doorIndex = session.Device.GatewayDoorIndex > 0 ? session.Device.GatewayDoorIndex : 1;
|
|
bool ok = EventByDeploy.CHCNetSDK.NET_DVR_ControlGateway(session.UserId, doorIndex, action);
|
|
if (!ok)
|
|
{
|
|
error = "NET_DVR_ControlGateway failed, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action + ", " + BuildSdkError("NET_DVR_ControlGateway");
|
|
_logger.Error(error);
|
|
return false;
|
|
}
|
|
|
|
_logger.Info("NET_DVR_ControlGateway succeeded, deviceId=" + deviceId + ", doorIndex=" + doorIndex + ", action=" + action);
|
|
return true;
|
|
}
|
|
|
|
private void LogConfiguredTerminalProfile(HikvisionAttendanceWindowsService.DeviceConfig device)
|
|
{
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(device.Model))
|
|
parts.Add("model=" + device.Model.Trim());
|
|
if (!string.IsNullOrWhiteSpace(device.FirmwareVersion))
|
|
parts.Add("firmware=" + device.FirmwareVersion.Trim());
|
|
if (!string.IsNullOrWhiteSpace(device.SerialNumber))
|
|
parts.Add("serial=" + device.SerialNumber.Trim());
|
|
if (!string.IsNullOrWhiteSpace(device.SubnetMask))
|
|
parts.Add("mask=" + device.SubnetMask.Trim());
|
|
if (!string.IsNullOrWhiteSpace(device.DefaultGateway))
|
|
parts.Add("gateway=" + device.DefaultGateway.Trim());
|
|
|
|
if (parts.Count > 0)
|
|
_logger.Info("Terminal profile (config): " + string.Join(", ", parts) + ".");
|
|
}
|
|
|
|
private static string FormatSdkSerial(byte[]? bytes)
|
|
{
|
|
if (bytes == null || bytes.Length == 0)
|
|
return "";
|
|
try
|
|
{
|
|
return Encoding.ASCII.GetString(bytes).TrimEnd('\0').Trim();
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private DeviceSession FindSession(string deviceId)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
if (key.Length == 0)
|
|
return null;
|
|
|
|
foreach (var s in _sessions)
|
|
{
|
|
if (DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key)
|
|
return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void CopyUtf8(string value, byte[] target)
|
|
{
|
|
if (target == null || target.Length == 0 || string.IsNullOrEmpty(value))
|
|
return;
|
|
|
|
var src = Encoding.UTF8.GetBytes(value);
|
|
int n = Math.Min(src.Length, target.Length);
|
|
Buffer.BlockCopy(src, 0, target, 0, n);
|
|
}
|
|
|
|
// SDK callback: do not block (only enqueue). Signature must match Common.CHCNetSDK.MSGCallBack (same module as Init).
|
|
private void AlarmCallback(int lCommand, ref Common.CHCNetSDK.NET_DVR_ALARMER pAlarmer, IntPtr pAlarmInfo, uint dwBufLen, IntPtr pUser)
|
|
{
|
|
try
|
|
{
|
|
if (lCommand != Common.CHCNetSDK.COMM_ALARM_ACS)
|
|
return;
|
|
|
|
var acsAlarm = Marshal.PtrToStructure<EventByDeploy.CHCNetSDK.NET_DVR_ACS_ALARM_INFO>(pAlarmInfo);
|
|
|
|
var eventName = MapAcsEventName(acsAlarm.dwMajor, acsAlarm.dwMinor);
|
|
if (string.IsNullOrWhiteSpace(eventName))
|
|
eventName = "MAJOR_" + acsAlarm.dwMajor + "_MINOR_" + acsAlarm.dwMinor;
|
|
|
|
uint rawMajor = acsAlarm.dwMajor;
|
|
uint rawMinor = acsAlarm.dwMinor;
|
|
|
|
var info = acsAlarm.struAcsEventInfo;
|
|
uint empNo = info.dwEmployeeNo;
|
|
string cardNo = DecodeCardNo(info.byCardNo);
|
|
|
|
// attendance_log identity = employee number only (not card).
|
|
bool hasEmployee = empNo != 0;
|
|
if (!hasEmployee)
|
|
return;
|
|
|
|
string employeeNoString = empNo.ToString(CultureInfo.InvariantCulture);
|
|
|
|
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");
|
|
bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0";
|
|
|
|
var attendanceEvent = new AttendanceEvent(
|
|
deviceId,
|
|
deviceIp,
|
|
ts,
|
|
(int)empNo,
|
|
employeeNoString,
|
|
employeeNoString,
|
|
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 hasEmpNum = empNum != 0;
|
|
bool hasEmpStr = !string.IsNullOrWhiteSpace(userIdStr);
|
|
// Require a parseable employee number — card-only / system events are not attendance_log rows.
|
|
string? employeeNoString = null;
|
|
int? employeeNo = null;
|
|
if (hasEmpStr && int.TryParse(userIdStr.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFromStr))
|
|
{
|
|
employeeNoString = parsedFromStr.ToString(CultureInfo.InvariantCulture);
|
|
employeeNo = parsedFromStr;
|
|
}
|
|
else if (hasEmpNum)
|
|
{
|
|
employeeNo = (int)empNum;
|
|
employeeNoString = empNum.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
else
|
|
return false;
|
|
|
|
bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0";
|
|
|
|
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,
|
|
employeeNoString,
|
|
employeeNoString,
|
|
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.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified);
|
|
|
|
// Device/SDK wall-clock — do not treat as UTC.
|
|
return new DateTime(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond, DateTimeKind.Unspecified);
|
|
}
|
|
|
|
private static string DecodeCardNo(byte[] bytes)
|
|
{
|
|
if (bytes == null || bytes.Length == 0)
|
|
return "";
|
|
try
|
|
{
|
|
return Encoding.UTF8.GetString(bytes).TrimEnd('\0').Trim();
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private void QueueWriterLoop(CancellationToken token)
|
|
{
|
|
StreamWriter sw = null;
|
|
try
|
|
{
|
|
// Append continuously; flush per event to keep “real-time” feel.
|
|
sw = new StreamWriter(new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite));
|
|
sw.AutoFlush = true;
|
|
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
_queueSignal.Wait(token);
|
|
|
|
AttendanceEvent ev;
|
|
while (_queue.TryDequeue(out ev))
|
|
{
|
|
Interlocked.Decrement(ref _queueSize);
|
|
lock (_csvWriteLock)
|
|
{
|
|
sw.WriteLine(ToCsvLine(ev));
|
|
}
|
|
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
|
|
AppendAttendanceToTextFileSafely(ev);
|
|
WriteAttendanceToDatabase(ev);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// expected
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("QueueWriterLoop failed", ex);
|
|
}
|
|
finally
|
|
{
|
|
try { if (sw != null) sw.Dispose(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
private void ExportLoop(CancellationToken token)
|
|
{
|
|
// Simple exporter placeholder: periodically copies the latest CSV for HR/payroll integration.
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
token.WaitHandle.WaitOne(TimeSpan.FromMinutes(1));
|
|
if (token.IsCancellationRequested)
|
|
break;
|
|
|
|
lock (_csvWriteLock)
|
|
{
|
|
if (File.Exists(_csvPath))
|
|
{
|
|
File.Copy(_csvPath, _exportPath, overwrite: true);
|
|
File.Copy(_csvPath, _hrExportPath, overwrite: true);
|
|
_logger.Info("ExportLoop: attendance_events.csv copied to attendance_export.csv and HR export path.");
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("ExportLoop failed", ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string ToCsvLine(AttendanceEvent ev)
|
|
{
|
|
string Q(string? s)
|
|
{
|
|
if (s == null) return "\"\"";
|
|
s = s.Replace("\"", "\"\"");
|
|
return "\"" + s + "\"";
|
|
}
|
|
|
|
return string.Join(",",
|
|
Q(ev.DeviceId),
|
|
Q(ev.DeviceIp),
|
|
Q(ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")),
|
|
ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : "",
|
|
Q(ev.UserIdentifier),
|
|
Q(ev.CardNo),
|
|
ev.DoorNo.ToString(),
|
|
ev.ReaderNo.ToString(),
|
|
Q(ev.AttendanceMethod),
|
|
Q(ev.EventName),
|
|
Q(ev.EventType),
|
|
Q(ev.Source),
|
|
ev.IsSuccess ? "1" : "0",
|
|
ev.RawMajor.ToString(),
|
|
ev.RawMinor.ToString());
|
|
}
|
|
|
|
private void AppendAttendanceCsvLineSafely(string csvLine)
|
|
{
|
|
using (var fs = new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
|
using (var writer = new StreamWriter(fs, Encoding.UTF8))
|
|
{
|
|
writer.WriteLine(csvLine);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { if (_cts != null) _cts.Cancel(); } catch { /* ignore */ }
|
|
}
|
|
|
|
/// <summary>Human-readable one-line record. Re-enable SQL path via EnableDatabasePersistence + SqlConnectionString.</summary>
|
|
private void AppendAttendanceToTextFileSafely(AttendanceEvent ev)
|
|
{
|
|
try
|
|
{
|
|
var line = FormatAttendanceTextLine(ev);
|
|
lock (_attendanceTextLock)
|
|
{
|
|
File.AppendAllText(_attendanceTextPath, line + Environment.NewLine, Encoding.UTF8);
|
|
}
|
|
|
|
_logger.Info("Attendance text file: write OK path=" + _attendanceTextPath + " Source=" + ev.Source);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("Attendance text file: write FAILED path=" + _attendanceTextPath + " Source=" + ev.Source, ex);
|
|
}
|
|
}
|
|
|
|
private static string FormatAttendanceTextLine(AttendanceEvent ev)
|
|
{
|
|
string emp = ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString() : "";
|
|
return "[" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + "] " +
|
|
"Device=" + TxtToken(ev.DeviceId) +
|
|
" IP=" + TxtToken(ev.DeviceIp) +
|
|
" EmployeeId=" + TxtToken(emp) +
|
|
" UserIdentifier=" + TxtToken(ev.UserIdentifier) +
|
|
" CardNo=" + TxtToken(ev.CardNo) +
|
|
" Method=" + TxtToken(ev.AttendanceMethod) +
|
|
" Event=" + TxtToken(ev.EventName) +
|
|
" Door=" + ev.DoorNo +
|
|
" Reader=" + ev.ReaderNo +
|
|
" Success=" + (ev.IsSuccess ? "true" : "false") +
|
|
" Major=" + ev.RawMajor +
|
|
" Minor=" + ev.RawMinor +
|
|
" Source=" + TxtToken(ev.Source);
|
|
}
|
|
|
|
private static string TxtToken(string? value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
return "";
|
|
|
|
if (value.IndexOf(' ') >= 0 || value.IndexOf('=') >= 0)
|
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
|
|
|
return value;
|
|
}
|
|
|
|
private void TryUpdateMachineRuntimeState(string machineId, string status, DateTime? lastSyncDateUtc, int? totalUsers)
|
|
{
|
|
if (!_config.EnableDbIntegration || _attendanceMachineRepository == null || string.IsNullOrWhiteSpace(machineId))
|
|
return;
|
|
DateTime? lastSyncLocal = null;
|
|
if (lastSyncDateUtc.HasValue)
|
|
lastSyncLocal = NormalizeForDbLocalTime(lastSyncDateUtc.Value);
|
|
_attendanceMachineRepository.UpdateMachineSyncState(machineId, status, lastSyncLocal, totalUsers, out var err);
|
|
if (!string.IsNullOrWhiteSpace(err))
|
|
_logger.Warn("Machine state update failed machine_ip=" + machineId + " err=" + err);
|
|
}
|
|
|
|
private string ResolveMachineIpForDbLookup(string deviceId)
|
|
{
|
|
var bySession = _sessions.FirstOrDefault(s =>
|
|
string.Equals(DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
|
|
if (bySession != null && !string.IsNullOrWhiteSpace(bySession.Device.Ip))
|
|
return bySession.Device.Ip.Trim();
|
|
|
|
var byRuntime = (ResolveRuntimeDevices() ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>())
|
|
.FirstOrDefault(d => string.Equals(DeviceIdentity.CanonicalLookupKey(d.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
|
|
if (byRuntime != null && !string.IsNullOrWhiteSpace(byRuntime.Ip))
|
|
return byRuntime.Ip.Trim();
|
|
|
|
return "";
|
|
}
|
|
|
|
private bool IsTerryAttendanceMachineIp(string machineIp)
|
|
{
|
|
var ip = (machineIp ?? "").Trim();
|
|
if (ip.Length == 0)
|
|
return false;
|
|
|
|
foreach (var configured in _config.TerryAttendanceMachineIps ?? Enumerable.Empty<string>())
|
|
{
|
|
if (string.Equals((configured ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static DateTime NormalizeForDbLocalTime(DateTime value)
|
|
{
|
|
// Persist as Unspecified wall-clock for MySQL DATETIME (no timezone conversion).
|
|
return new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second,
|
|
DateTimeKind.Unspecified);
|
|
}
|
|
|
|
private AttendancePersistOutcome WriteAttendanceToDatabase(AttendanceEvent ev, AttendanceDeviceCycleStats? stats = null)
|
|
{
|
|
if (_config.EnableDbIntegration && _config.EnableAttendanceDbPersistence && _attendanceLogRepository != null)
|
|
{
|
|
var employeeNoString = (ev.EmployeeNoString ?? ev.UserIdentifier ?? "").Trim();
|
|
if (string.IsNullOrWhiteSpace(employeeNoString))
|
|
{
|
|
_logger.Diag("attendance", "skip no employeeNoString major=" + ev.RawMajor + " minor=" + ev.RawMinor +
|
|
" event=\"" + (ev.EventName ?? "") + "\"");
|
|
return AttendancePersistOutcome.SkippedNoEmployee;
|
|
}
|
|
|
|
if (!int.TryParse(employeeNoString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var employeeNo))
|
|
{
|
|
_logger.Diag("attendance", "skip invalid employeeNoString=\"" + employeeNoString + "\"");
|
|
return AttendancePersistOutcome.SkippedNoEmployee;
|
|
}
|
|
|
|
var acNo = employeeNo.ToString(CultureInfo.InvariantCulture);
|
|
var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0;
|
|
var machineId = ev.DeviceId ?? "";
|
|
var machineIp = (ev.DeviceIp ?? "").Trim();
|
|
// Preserve device/site wall-clock — never store UTC-shifted checktime.
|
|
var checkTimeLocal = NormalizeForDbLocalTime(ev.Timestamp);
|
|
checkTimeLocal = DateTime.SpecifyKind(checkTimeLocal, DateTimeKind.Unspecified);
|
|
var dateOnly = checkTimeLocal.Date;
|
|
|
|
var rawTs = string.IsNullOrWhiteSpace(ev.DeviceTimestampRaw)
|
|
? checkTimeLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture)
|
|
: ev.DeviceTimestampRaw;
|
|
var offsetLog = string.IsNullOrWhiteSpace(ev.DeviceTimestampOffset) ? "00:00:00" : ev.DeviceTimestampOffset;
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_DEBUG]" +
|
|
" machine_id=" + machineId +
|
|
" machine_ip=" + machineIp +
|
|
" employee=" + acNo +
|
|
" source=" + (ev.Source ?? "") +
|
|
" raw_timestamp=" + rawTs +
|
|
" parsed_timestamp=" + checkTimeLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
|
|
" datetime_kind=" + checkTimeLocal.Kind +
|
|
" offset=" + offsetLog);
|
|
|
|
// Terry machines (by IP): always terry_attendance_log — no employee/worker_type lookup.
|
|
if (IsTerryAttendanceMachineIp(machineIp))
|
|
{
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_ROUTE] machine=" + machineId +
|
|
" ip=" + machineIp +
|
|
" employeeNo=" + acNo +
|
|
" route=TerryAttendanceMachineIps" +
|
|
" destination=terry_attendance_log");
|
|
|
|
var terryOk = _attendanceLogRepository.InsertTerryAttendance(
|
|
acNo,
|
|
checkTimeLocal,
|
|
0,
|
|
machineId,
|
|
inOutTypeId,
|
|
machineIp,
|
|
dateOnly,
|
|
dateOnly,
|
|
msg => _logger.Diag("attendance", msg),
|
|
out var terryDuplicate,
|
|
out var terryErr);
|
|
|
|
if (terryDuplicate)
|
|
{
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_ROUTE] employeeNo=" + acNo +
|
|
" destination=terry_attendance_log result=DUPLICATE");
|
|
return AttendancePersistOutcome.Duplicate;
|
|
}
|
|
|
|
if (!terryOk)
|
|
{
|
|
_logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED destination=terry_attendance_log ac_no=" + acNo +
|
|
" device=" + machineId + " reason=\"" + terryErr + "\"");
|
|
_logger.Totals.AttendanceFailed++;
|
|
return AttendancePersistOutcome.DbFailed;
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_ROUTE] employeeNo=" + acNo +
|
|
" destination=terry_attendance_log result=INSERTED");
|
|
_logger.Totals.AttendanceInserted++;
|
|
stats?.InsertedPunches.Add((machineIp, acNo, checkTimeLocal));
|
|
return AttendancePersistOutcome.DbInserted;
|
|
}
|
|
|
|
// Non-Terry machines: existing attendance_log insertion flow (no Terry IP routing).
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_ROUTE] machine=" + machineId +
|
|
" ip=" + machineIp +
|
|
" employeeNo=" + acNo +
|
|
" destination=attendance_log");
|
|
|
|
var ok = _attendanceLogRepository.InsertAttendance(
|
|
acNo,
|
|
employeeNo,
|
|
checkTimeLocal,
|
|
0,
|
|
machineId,
|
|
inOutTypeId,
|
|
machineIp,
|
|
dateOnly,
|
|
msg => _logger.Diag("attendance", msg),
|
|
out var err);
|
|
|
|
if (!ok)
|
|
{
|
|
_logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED destination=attendance_log ac_no=" + acNo +
|
|
" device=" + machineId + " reason=\"" + err + "\"");
|
|
_logger.Totals.AttendanceFailed++;
|
|
return AttendancePersistOutcome.DbFailed;
|
|
}
|
|
|
|
_logger.Ops(OpsMarkers.Attendance,
|
|
"[ATTENDANCE_ROUTE] employeeNo=" + acNo +
|
|
" destination=attendance_log result=INSERTED");
|
|
_logger.Totals.AttendanceInserted++;
|
|
stats?.InsertedPunches.Add((machineIp, acNo, checkTimeLocal));
|
|
return AttendancePersistOutcome.DbInserted;
|
|
}
|
|
|
|
if (!_config.EnableDatabasePersistence)
|
|
return AttendancePersistOutcome.PersistedWithoutDb;
|
|
|
|
if (string.IsNullOrWhiteSpace(_config.SqlConnectionString))
|
|
return AttendancePersistOutcome.DbFailed;
|
|
|
|
try
|
|
{
|
|
using (var conn = new SqlConnection(_config.SqlConnectionString))
|
|
{
|
|
conn.Open();
|
|
var sql = "INSERT INTO " + _config.AttendanceTableName + " " +
|
|
"(DeviceId, DeviceIp, EmployeeId, UserIdentifier, EventType, AttendanceMethod, EventTimestamp, DoorNo, ReaderNo, IsSuccess, RawMajor, RawMinor, EventSource) " +
|
|
"VALUES (@DeviceId,@DeviceIp,@EmployeeId,@UserIdentifier,@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.EmployeeNoString ?? ev.UserIdentifier) ?? 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();
|
|
}
|
|
}
|
|
return AttendancePersistOutcome.DbInserted;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("WriteAttendanceToDatabase failed (SQL Server path)", ex);
|
|
return AttendancePersistOutcome.DbFailed;
|
|
}
|
|
}
|
|
|
|
private void TriggerAutoDoor(string deviceId)
|
|
{
|
|
Task.Run(() =>
|
|
{
|
|
string error;
|
|
if (!ControlDoor(deviceId, 1, out error))
|
|
return;
|
|
|
|
_logger.Info("AUTO_DOOR: opened gateway after successful access, deviceId=" + deviceId +
|
|
", delayCloseSec=" + _config.AutoDoorCloseDelaySeconds);
|
|
|
|
if (_config.AutoDoorCloseDelaySeconds <= 0)
|
|
return;
|
|
|
|
try
|
|
{
|
|
Thread.Sleep(TimeSpan.FromSeconds(_config.AutoDoorCloseDelaySeconds));
|
|
ControlDoor(deviceId, 0, out error);
|
|
_logger.Info("AUTO_DOOR: close after delay, deviceId=" + deviceId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("TriggerAutoDoor failed", ex);
|
|
}
|
|
});
|
|
}
|
|
|
|
private string BuildSdkError(string operation)
|
|
{
|
|
try
|
|
{
|
|
uint err = Common.CHCNetSDK.NET_DVR_GetLastError();
|
|
return operation + " errorCode=" + err + " (" + TranslateSdkErrorCode(err) + ")";
|
|
}
|
|
catch
|
|
{
|
|
return operation + " errorCode=unknown";
|
|
}
|
|
}
|
|
|
|
private void LogSdkFailure(string operation, uint errorCode, string? deviceId, string? ip, string? detail, bool isWarning)
|
|
{
|
|
var lines = new[]
|
|
{
|
|
"SDK operation failed",
|
|
"Operation: " + operation,
|
|
"DeviceId: " + (string.IsNullOrWhiteSpace(deviceId) ? "-" : deviceId),
|
|
"IP: " + (string.IsNullOrWhiteSpace(ip) ? "-" : ip),
|
|
"Reason: " + TranslateSdkErrorCode(errorCode),
|
|
"Original: " + operation + " errorCode=" + errorCode,
|
|
"Detail: " + (string.IsNullOrWhiteSpace(detail) ? "-" : detail)
|
|
};
|
|
var msg = string.Join(Environment.NewLine, lines);
|
|
if (isWarning)
|
|
_logger.Warn(msg);
|
|
else
|
|
_logger.Error(msg);
|
|
}
|
|
|
|
private void LogSdkFailureFromText(string operation, string sdkErrorText, string? deviceId, string? ip, bool isWarning)
|
|
{
|
|
if (TryExtractSdkErrorCodeFromText(sdkErrorText, out var code))
|
|
{
|
|
LogSdkFailure(operation, code, deviceId, ip, sdkErrorText, isWarning);
|
|
return;
|
|
}
|
|
|
|
var lines = new[]
|
|
{
|
|
"SDK operation failed",
|
|
"Operation: " + operation,
|
|
"DeviceId: " + (string.IsNullOrWhiteSpace(deviceId) ? "-" : deviceId),
|
|
"IP: " + (string.IsNullOrWhiteSpace(ip) ? "-" : ip),
|
|
"Reason: Unable to parse SDK error code",
|
|
"Original: " + sdkErrorText,
|
|
"Detail: " + sdkErrorText
|
|
};
|
|
var msg = string.Join(Environment.NewLine, lines);
|
|
if (isWarning)
|
|
_logger.Warn(msg);
|
|
else
|
|
_logger.Error(msg);
|
|
}
|
|
|
|
private static bool TryExtractSdkErrorCodeFromText(string text, out uint code)
|
|
{
|
|
code = 0;
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return false;
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, @"\berr(orCode)?\s*=?\s*(\d+)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (!m.Success)
|
|
return false;
|
|
return uint.TryParse(m.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out code);
|
|
}
|
|
|
|
private static string TranslateSdkErrorCode(uint code)
|
|
{
|
|
switch (code)
|
|
{
|
|
case 0: return "No error";
|
|
case 1: return "Password error";
|
|
case 2: return "No sufficient privilege";
|
|
case 3: return "SDK not initialized (NET_DVR_NOINIT)";
|
|
case 4: return "Channel error";
|
|
case 5: return "No such user";
|
|
case 6: return "Version mismatch";
|
|
case 7: return "Connection failed (network/device unreachable)";
|
|
case 8: return "Function not supported";
|
|
case 9: return "Illegal parameter";
|
|
case 10: return "Channel occupied";
|
|
case 11: return "SDK XML config error";
|
|
case 17: return "Parameter error";
|
|
case 23: return "No enough memory";
|
|
case 29: return "No permission";
|
|
case 41: return "Operation timeout";
|
|
case 72: return "Data send failed";
|
|
case 73: return "Data receive failed";
|
|
case 84: return "Create socket failed";
|
|
case 109: return "Network timeout / no response from device";
|
|
default: return "Unknown SDK error";
|
|
}
|
|
}
|
|
|
|
private sealed class DeviceSession
|
|
{
|
|
public DeviceSession(HikvisionAttendanceWindowsService.DeviceConfig device, int userId, int alarmHandle)
|
|
{
|
|
Device = device;
|
|
UserId = userId;
|
|
AlarmHandle = alarmHandle;
|
|
}
|
|
|
|
public HikvisionAttendanceWindowsService.DeviceConfig Device { get; private set; }
|
|
public int UserId { get; set; }
|
|
public int AlarmHandle { get; set; }
|
|
}
|
|
|
|
private sealed class AttendanceEvent
|
|
{
|
|
public AttendanceEvent(
|
|
string deviceId,
|
|
string deviceIp,
|
|
DateTime timestamp,
|
|
int? employeeNo,
|
|
string? employeeNoString,
|
|
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;
|
|
EmployeeNoString = employeeNoString;
|
|
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; }
|
|
/// <summary>Original Hikvision time string (diagnostic only; not persisted as a column).</summary>
|
|
public string DeviceTimestampRaw { get; set; } = "";
|
|
/// <summary>Offset from raw Hikvision timestamp when present (diagnostic only).</summary>
|
|
public string DeviceTimestampOffset { get; set; } = "";
|
|
public int? EmployeeNo { get; }
|
|
/// <summary>Hikvision employeeNoString — sole identity for attendance_log.ac_no.</summary>
|
|
public string? EmployeeNoString { 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() ?? "") + "|" + (EmployeeNoString ?? UserIdentifier ?? "") + "|" +
|
|
DoorNo + "|" + ReaderNo;
|
|
}
|
|
}
|
|
|