krona-staff/apps/KronaStaffTerminal/Services/FingerprintScanner.cs

648 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text;
using libzkfpcsharp;
namespace KronaStaffTerminal.Services;
public enum ScannerState
{
Disconnected,
Connected,
Busy,
DriverError
}
public sealed record ScannerDeviceInfo(
int Index,
string Model,
string SerialNumber,
string DisplayName);
public interface IFingerprintScanner : IDisposable
{
ScannerState State { get; }
int DeviceCount { get; }
int ReaderIndex { get; }
IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices { get; }
bool IsConnected { get; }
int SkippedTemplateCount { get; }
event Action<int, int>? FingerRecognized;
event Action<int>? RegistrationProgress;
event Action<int, string>? RegistrationCompleted;
event Action<string>? StatusChanged;
event Action<ScannerState, string>? StateChanged;
event Action? ConnectionLost;
event Action? RegistrationCancelled;
IReadOnlyList<ScannerDeviceInfo> DiscoverDevices();
void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates);
void Disconnect();
void BeginRegistration(int fingerprintId);
void CancelRegistration();
}
public sealed class FingerprintScanner : IFingerprintScanner
{
private const int TemplateSize = 2048;
private readonly object _gate = new();
private readonly byte[][] _registrationSamples = [new byte[TemplateSize], new byte[TemplateSize], new byte[TemplateSize]];
private IntPtr _device = IntPtr.Zero;
private IntPtr _database = IntPtr.Zero;
private CancellationTokenSource? _captureCancellation;
private Task? _captureTask;
private int _registrationCount;
private int? _registrationFingerprintId;
private DateTimeOffset _registrationExpiresAtUtc;
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
private bool _fingerOnSensor;
private int _cleanupScheduled;
private List<ScannerDeviceInfo> _discoveredDevices = [];
private List<(int FingerprintId, string Template)> _lastLoadedTemplates = [];
private CancellationTokenSource? _reconnectCancellation;
private Task? _reconnectTask;
private bool _autoReconnectEnabled = true;
public event Action<int, int>? FingerRecognized;
public event Action<int>? RegistrationProgress;
event Action<int, string>? IFingerprintScanner.RegistrationCompleted
{
add => RegistrationCompleted += value;
remove => RegistrationCompleted -= value;
}
public event Action<int, string>? RegistrationCompleted;
public event Action<string>? StatusChanged;
public event Action<ScannerState, string>? StateChanged;
public event Action? ConnectionLost;
public event Action? RegistrationCancelled;
public ScannerState State { get; private set; } = ScannerState.Disconnected;
public int DeviceCount { get; private set; }
public int ReaderIndex { get; private set; }
public int SkippedTemplateCount { get; private set; }
public IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices => _discoveredDevices;
public bool IsConnected => _device != IntPtr.Zero && State == ScannerState.Connected;
public IReadOnlyList<ScannerDeviceInfo> DiscoverDevices()
{
lock (_gate)
{
var devices = new List<ScannerDeviceInfo>();
var seenSerials = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var initResult = zkfp2.Init();
if (initResult != 0 && initResult != 1)
{
CrashLogService.WriteDiagnostic($"zkfp2.Init returned {initResult} during discovery.");
_discoveredDevices = devices;
DeviceCount = 0;
return devices;
}
try
{
var rawCount = Math.Max(0, zkfp2.GetDeviceCount());
for (var i = 0; i < rawCount; i++)
{
// If this device is currently open by us, use it or skip opening
IntPtr handle;
var shouldClose = false;
if (_device != IntPtr.Zero && ReaderIndex == i)
{
handle = _device;
}
else
{
handle = zkfp2.OpenDevice(i);
shouldClose = true;
}
if (handle == IntPtr.Zero)
{
continue;
}
try
{
var serial = QueryDeviceStringParameter(handle, 1101);
if (string.IsNullOrWhiteSpace(serial))
{
serial = QueryDeviceStringParameter(handle, 1102);
}
if (string.IsNullOrWhiteSpace(serial))
{
serial = QueryDeviceStringParameter(handle, 106);
}
var model = QueryDeviceStringParameter(handle, 1015);
if (string.IsNullOrWhiteSpace(model))
{
model = "ZKTeco SLK20R";
}
// Eliminate duplicate devices:
// ZKFinger SDK 5.3 often exposes 2 device slots for 1 physical USB scanner.
// Filter out ghost handles by checking serial number deduplication.
if (!string.IsNullOrWhiteSpace(serial))
{
if (seenSerials.Contains(serial))
{
CrashLogService.WriteDiagnostic($"Device index {i} skipped as duplicate serial: {serial}");
continue;
}
seenSerials.Add(serial);
}
else if (devices.Count > 0 && rawCount > 1)
{
// If no serial number could be queried but a previous reader was already found
// on SLK20R driver, skip the secondary phantom slot.
CrashLogService.WriteDiagnostic($"Device index {i} skipped as phantom secondary reader slot.");
continue;
}
var displayName = !string.IsNullOrWhiteSpace(serial)
? $"{model} ({serial})"
: $"{model} (сканер №{devices.Count + 1})";
devices.Add(new ScannerDeviceInfo(i, model, serial, displayName));
}
finally
{
if (shouldClose && handle != IntPtr.Zero)
{
zkfp2.CloseDevice(handle);
}
}
}
}
finally
{
if (_device == IntPtr.Zero)
{
zkfp2.Terminate();
}
}
_discoveredDevices = devices;
DeviceCount = devices.Count;
CrashLogService.WriteDiagnostic($"Device discovery completed: {devices.Count} unique device(s) found.");
return devices;
}
}
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
{
_lastLoadedTemplates = templates.ToList();
_autoReconnectEnabled = true;
StopReconnectLoop();
ConnectInternal(readerIndex, _lastLoadedTemplates);
}
private void ConnectInternal(int readerIndex, IReadOnlyList<(int FingerprintId, string Template)> templates)
{
Disconnect();
SkippedTemplateCount = 0;
var result = zkfp2.Init();
if (result != 0 && result != 1)
{
SetState(ScannerState.DriverError, $"Драйвер сканера вернул ошибку инициализации (код {result}).");
CrashLogService.WriteDiagnostic($"Scanner initialization failed: code {result}");
StartReconnectLoop();
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}). Проверьте драйвер.");
}
var available = DiscoverDevices();
if (available.Count == 0)
{
zkfp2.Terminate();
SetState(ScannerState.Disconnected, "Подключённые сканеры не найдены.");
CrashLogService.WriteDiagnostic("Scanner connect failed: no devices found.");
StartReconnectLoop();
throw new InvalidOperationException("Подключённые сканеры не найдены. Подключите USB-сканер.");
}
var chosenDevice = available.FirstOrDefault(d => d.Index == readerIndex) ?? available[0];
var deviceIndex = chosenDevice.Index;
var candidate = zkfp2.OpenDevice(deviceIndex);
if (candidate == IntPtr.Zero)
{
zkfp2.Terminate();
SetState(ScannerState.DriverError, "Не удалось открыть сканер. Возможно, устройство занято.");
CrashLogService.WriteDiagnostic($"Failed to open scanner at index {deviceIndex}.");
StartReconnectLoop();
throw new InvalidOperationException("Не удалось открыть сканер. Переподключите USB-кабель.");
}
ReaderIndex = deviceIndex;
_device = candidate;
_database = zkfp2.DBInit();
if (_database == IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
zkfp2.Terminate();
SetState(ScannerState.DriverError, "Не удалось инициализировать базу биометрических шаблонов.");
CrashLogService.WriteDiagnostic("zkfp2.DBInit failed.");
StartReconnectLoop();
throw new InvalidOperationException("Не удалось создать базу биометрических шаблонов.");
}
foreach (var item in templates)
{
try
{
var template = Convert.FromBase64String(item.Template);
if (template.Length == 0 || zkfp2.DBAdd(_database, item.FingerprintId, template) != 0)
{
SkippedTemplateCount++;
}
}
catch (FormatException)
{
SkippedTemplateCount++;
}
}
_captureCancellation = new CancellationTokenSource();
var device = _device;
var database = _database;
_captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token));
var modelInfo = !string.IsNullOrWhiteSpace(chosenDevice.SerialNumber)
? $"{chosenDevice.Model} [SN: {chosenDevice.SerialNumber}]"
: chosenDevice.Model;
SetState(ScannerState.Connected, $"Сканер {modelInfo} подключён. Приложите палец.");
CrashLogService.WriteDiagnostic($"Scanner connected: {modelInfo} at index {deviceIndex}. Loaded {templates.Count - SkippedTemplateCount} templates.");
}
public void BeginRegistration(int fingerprintId)
{
lock (_gate)
{
_registrationFingerprintId = fingerprintId;
_registrationCount = 0;
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
}
SetState(ScannerState.Busy, "Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
}
public void CancelRegistration()
{
lock (_gate)
{
_registrationFingerprintId = null;
_registrationCount = 0;
_registrationExpiresAtUtc = default;
}
SetState(IsConnected ? ScannerState.Connected : ScannerState.Disconnected, "Регистрация отпечатка отменена.");
RegistrationCancelled?.Invoke();
}
private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
{
var parameter = new byte[4];
var size = 4;
var width = 0;
var height = 0;
zkfp2.GetParameters(device, 1, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref width);
size = 4;
zkfp2.GetParameters(device, 2, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref height);
var image = new byte[Math.Max(1, width * height)];
var captured = new byte[TemplateSize];
var nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
try
{
while (!cancellationToken.IsCancellationRequested)
{
ExpireRegistrationIfNeeded();
if (DateTimeOffset.UtcNow >= nextHealthCheck)
{
var healthSize = 4;
if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0)
{
HandleConnectionLoss("Потеряна связь со сканером (сбой проверки состояния).");
return;
}
nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
}
var capturedLength = TemplateSize;
var acquireResult = zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength);
if (acquireResult == 0)
{
var now = DateTimeOffset.UtcNow;
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
{
_fingerOnSensor = true;
_lastProcessedCapture = now;
ProcessCapture(database, captured, capturedLength);
}
}
else if (acquireResult is -6 or -7 or -8 or -10 or -17)
{
// Negative critical device error codes indicate USB disconnect or driver fault
HandleConnectionLoss($"Ошибка сканера (код {acquireResult}).");
return;
}
else
{
_fingerOnSensor = false;
}
Thread.Sleep(150);
}
}
catch (Exception exception)
{
CrashLogService.Write(exception);
if (!cancellationToken.IsCancellationRequested)
{
HandleConnectionLoss($"Сбой потока чтения сканера: {exception.Message}");
}
}
}
private void HandleConnectionLoss(string reason)
{
CrashLogService.WriteDiagnostic($"Scanner connection lost: {reason}");
SetState(ScannerState.Disconnected, reason);
ConnectionLost?.Invoke();
if (_autoReconnectEnabled)
{
StartReconnectLoop();
}
}
private void StartReconnectLoop()
{
lock (_gate)
{
if (_reconnectTask is not null && !_reconnectTask.IsCompleted) return;
_reconnectCancellation = new CancellationTokenSource();
var token = _reconnectCancellation.Token;
_reconnectTask = Task.Run(() => ReconnectLoopAsync(token));
}
}
private void StopReconnectLoop()
{
lock (_gate)
{
_reconnectCancellation?.Cancel();
_reconnectCancellation?.Dispose();
_reconnectCancellation = null;
_reconnectTask = null;
}
}
private async Task ReconnectLoopAsync(CancellationToken token)
{
CrashLogService.WriteDiagnostic("Starting background scanner auto-reconnect loop...");
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(2500, token);
if (token.IsCancellationRequested) return;
var devices = DiscoverDevices();
if (devices.Count > 0)
{
CrashLogService.WriteDiagnostic($"Scanner detected during reconnect: {devices[0].DisplayName}. Connecting...");
ConnectInternal(ReaderIndex, _lastLoadedTemplates);
return; // Successfully reconnected!
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
CrashLogService.WriteDiagnostic($"Reconnect attempt failed: {ex.Message}");
}
}
}
private void ExpireRegistrationIfNeeded()
{
lock (_gate)
{
if (_registrationFingerprintId is null || DateTimeOffset.UtcNow < _registrationExpiresAtUtc) return;
_registrationFingerprintId = null;
_registrationCount = 0;
_registrationExpiresAtUtc = default;
SetState(ScannerState.Connected, "Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
RegistrationCancelled?.Invoke();
}
}
private void ProcessCapture(IntPtr database, byte[] captured, int capturedLength)
{
lock (_gate)
{
if (_registrationFingerprintId is int fingerprintId)
{
if (_registrationCount == 0)
{
var existingId = 0;
var existingScore = 0;
if (zkfp2.DBIdentify(database, captured, ref existingId, ref existingScore) == 0)
{
_registrationFingerprintId = null;
_registrationExpiresAtUtc = default;
SetState(ScannerState.Connected, "Этот палец уже зарегистрирован. Регистрация отменена.");
RegistrationCancelled?.Invoke();
return;
}
}
if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
{
SetState(ScannerState.Busy, "Это другой палец. Приложите тот же палец ещё раз.");
return;
}
Array.Clear(_registrationSamples[_registrationCount]);
Array.Copy(captured, _registrationSamples[_registrationCount], capturedLength);
_registrationCount++;
RegistrationProgress?.Invoke(_registrationCount);
if (_registrationCount < 3)
{
SetState(ScannerState.Busy, $"Скан принят: {_registrationCount}/3. Уберите палец и приложите снова.");
return;
}
var merged = new byte[TemplateSize];
var mergedLength = TemplateSize;
var result = zkfp2.DBMerge(database, _registrationSamples[0], _registrationSamples[1], _registrationSamples[2], merged, ref mergedLength);
if (result == 0)
{
zkfp2.DBDel(database, fingerprintId);
result = zkfp2.DBAdd(database, fingerprintId, merged);
}
_registrationFingerprintId = null;
_registrationCount = 0;
_registrationExpiresAtUtc = default;
if (result == 0)
{
var templateBase64 = zkfp2.BlobToBase64(merged, mergedLength);
RegistrationCompleted?.Invoke(fingerprintId, templateBase64);
SetState(ScannerState.Connected, "Отпечаток успешно зарегистрирован.");
CrashLogService.WriteDiagnostic($"Fingerprint id {fingerprintId} successfully merged and enrolled.");
}
else
{
SetState(ScannerState.Connected, $"Не удалось объединить сканы (код {result}). Повторите регистрацию.");
CrashLogService.WriteDiagnostic($"Fingerprint enrollment merge failed: code {result}");
}
return;
}
var foundId = 0;
var score = 0;
if (zkfp2.DBIdentify(database, captured, ref foundId, ref score) == 0)
{
FingerRecognized?.Invoke(foundId, score);
}
else
{
SetState(ScannerState.Connected, "Отпечаток не распознан.");
}
}
}
private void SetState(ScannerState newState, string message)
{
State = newState;
StatusChanged?.Invoke(message);
StateChanged?.Invoke(newState, message);
}
public void Disconnect()
{
_captureCancellation?.Cancel();
try
{
if (_captureTask is not null && !_captureTask.Wait(TimeSpan.FromSeconds(2)))
{
if (Interlocked.Exchange(ref _cleanupScheduled, 1) == 0)
{
_ = _captureTask.ContinueWith(
_ =>
{
ReleaseNativeResources();
ConnectionLost?.Invoke();
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
throw new TimeoutException(
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены после завершения операции.");
}
}
catch (AggregateException exception)
{
CrashLogService.Write(exception.Flatten());
}
ReleaseNativeResources();
}
private void ReleaseNativeResources()
{
lock (_gate)
{
_captureCancellation?.Dispose();
_captureCancellation = null;
_captureTask = null;
_fingerOnSensor = false;
if (_database != IntPtr.Zero)
{
zkfp2.DBFree(_database);
_database = IntPtr.Zero;
}
if (_device != IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
}
zkfp2.Terminate();
Interlocked.Exchange(ref _cleanupScheduled, 0);
if (State != ScannerState.DriverError)
{
State = ScannerState.Disconnected;
}
}
}
private static string QueryDeviceStringParameter(IntPtr device, int paramCode)
{
var buffer = new byte[256];
var size = buffer.Length;
if (zkfp2.GetParameters(device, paramCode, buffer, ref size) == 0 && size > 0)
{
var len = 0;
while (len < size && buffer[len] != 0) len++;
if (len > 0)
{
return Encoding.ASCII.GetString(buffer, 0, len).Trim();
}
}
return "";
}
public static bool ProbeSession0Support(out string diagnostic)
{
try
{
var init = zkfp2.Init();
if (init != 0 && init != 1)
{
diagnostic = $"zkfp2.Init failed with code {init} in current session.";
return false;
}
var count = zkfp2.GetDeviceCount();
zkfp2.Terminate();
if (count <= 0)
{
diagnostic = "ZKFinger driver reports 0 devices in Session 0 / non-interactive context.";
return false;
}
diagnostic = $"ZKFinger driver reported {count} device(s).";
return true;
}
catch (Exception ex)
{
diagnostic = $"Exception probing ZKFinger in Session 0: {ex.Message}";
return false;
}
}
public void Dispose()
{
_autoReconnectEnabled = false;
StopReconnectLoop();
try
{
Disconnect();
}
catch (TimeoutException exception)
{
CrashLogService.Write(exception);
}
}
}