feat(scanner): add IFingerprintScanner, deduplication, auto-reconnect, and state machine

This commit is contained in:
Ochenstarik 2026-09-24 16:20:39 +07:00
parent 14ce259a5b
commit c479bb60ca
2 changed files with 373 additions and 41 deletions

View file

@ -5,7 +5,7 @@ public static class CrashLogService
private const long MaximumLogSize = 1_048_576;
private static readonly object Gate = new();
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Krona",
"StaffTerminal",
"logs");
@ -29,6 +29,25 @@ public static class CrashLogService
}
}
public static void WriteDiagnostic(string message)
{
try
{
lock (Gate)
{
Directory.CreateDirectory(LogDirectory);
var path = Path.Combine(LogDirectory, "diagnostic.log");
RotateIfNeeded(path);
var entry = $"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}";
File.AppendAllText(path, entry);
}
}
catch
{
// Diagnostics must never throw
}
}
private static void RotateIfNeeded(string path)
{
if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return;

View file

@ -1,8 +1,47 @@
using System.Text;
using libzkfpcsharp;
namespace KronaStaffTerminal.Services;
public sealed class FingerprintScanner : IDisposable
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();
@ -17,55 +56,187 @@ public sealed class FingerprintScanner : IDisposable
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 bool IsConnected => _device != IntPtr.Zero;
public IReadOnlyList<ScannerDeviceInfo> DiscoveredDevices => _discoveredDevices;
public bool IsConnected => _device != IntPtr.Zero && State == ScannerState.Connected;
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
public IReadOnlyList<ScannerDeviceInfo> DiscoverDevices()
{
Disconnect();
SkippedTemplateCount = 0;
var result = zkfp2.Init();
if (result != 0)
lock (_gate)
{
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
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;
}
// GetDeviceCount returns the number of physical readers for the SLK20R
// driver shipped with ZKFinger SDK 5.3. Do not subtract a service slot:
// on a workstation with one reader that would hide the only device.
DeviceCount = Math.Max(0, zkfp2.GetDeviceCount());
if (DeviceCount <= 0)
try
{
zkfp2.Terminate();
throw new InvalidOperationException("Подключённые сканеры не найдены.");
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;
}
var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
var candidateIndexes = new[] { preferredIndex }
.Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex));
foreach (var candidateIndex in candidateIndexes)
if (handle == IntPtr.Zero)
{
var candidateDevice = zkfp2.OpenDevice(candidateIndex);
if (candidateDevice == IntPtr.Zero) continue;
ReaderIndex = candidateIndex;
_device = candidateDevice;
break;
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();
throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель.");
}
}
_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)
@ -73,7 +244,10 @@ public sealed class FingerprintScanner : IDisposable
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
zkfp2.Terminate();
throw new InvalidOperationException("Не удалось создать базу распознавания.");
SetState(ScannerState.DriverError, "Не удалось инициализировать базу биометрических шаблонов.");
CrashLogService.WriteDiagnostic("zkfp2.DBInit failed.");
StartReconnectLoop();
throw new InvalidOperationException("Не удалось создать базу биометрических шаблонов.");
}
foreach (var item in templates)
@ -96,7 +270,13 @@ public sealed class FingerprintScanner : IDisposable
var device = _device;
var database = _database;
_captureTask = Task.Run(() => CaptureLoop(device, database, _captureCancellation.Token));
StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец.");
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)
@ -108,7 +288,7 @@ public sealed class FingerprintScanner : IDisposable
_registrationExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1);
}
StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
SetState(ScannerState.Busy, "Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
}
public void CancelRegistration()
@ -119,6 +299,9 @@ public sealed class FingerprintScanner : IDisposable
_registrationCount = 0;
_registrationExpiresAtUtc = default;
}
SetState(IsConnected ? ScannerState.Connected : ScannerState.Disconnected, "Регистрация отпечатка отменена.");
RegistrationCancelled?.Invoke();
}
private void CaptureLoop(IntPtr device, IntPtr database, CancellationToken cancellationToken)
@ -146,15 +329,15 @@ public sealed class FingerprintScanner : IDisposable
var healthSize = 4;
if (zkfp2.GetParameters(device, 1, parameter, ref healthSize) != 0)
{
StatusChanged?.Invoke("Связь со сканером потеряна. Выполняется переподключение…");
ConnectionLost?.Invoke();
HandleConnectionLoss("Потеряна связь со сканером (сбой проверки состояния).");
return;
}
nextHealthCheck = DateTimeOffset.UtcNow.AddSeconds(5);
}
var capturedLength = TemplateSize;
if (zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength) == 0)
var acquireResult = zkfp2.AcquireFingerprint(device, image, captured, ref capturedLength);
if (acquireResult == 0)
{
var now = DateTimeOffset.UtcNow;
if (!_fingerOnSensor && now - _lastProcessedCapture > TimeSpan.FromMilliseconds(500))
@ -164,6 +347,12 @@ public sealed class FingerprintScanner : IDisposable
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;
@ -176,8 +365,70 @@ public sealed class FingerprintScanner : IDisposable
CrashLogService.Write(exception);
if (!cancellationToken.IsCancellationRequested)
{
StatusChanged?.Invoke("Ошибка сканера. Выполняется переподключение…");
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}");
}
}
}
@ -190,7 +441,7 @@ public sealed class FingerprintScanner : IDisposable
_registrationFingerprintId = null;
_registrationCount = 0;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
SetState(ScannerState.Connected, "Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
RegistrationCancelled?.Invoke();
}
}
@ -209,7 +460,7 @@ public sealed class FingerprintScanner : IDisposable
{
_registrationFingerprintId = null;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена.");
SetState(ScannerState.Connected, "Этот палец уже зарегистрирован. Регистрация отменена.");
RegistrationCancelled?.Invoke();
return;
}
@ -217,7 +468,7 @@ public sealed class FingerprintScanner : IDisposable
if (_registrationCount > 0 && zkfp2.DBMatch(database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
{
StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
SetState(ScannerState.Busy, "Это другой палец. Приложите тот же палец ещё раз.");
return;
}
@ -227,7 +478,7 @@ public sealed class FingerprintScanner : IDisposable
RegistrationProgress?.Invoke(_registrationCount);
if (_registrationCount < 3)
{
StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова.");
SetState(ScannerState.Busy, $"Скан принят: {_registrationCount}/3. Уберите палец и приложите снова.");
return;
}
@ -245,12 +496,15 @@ public sealed class FingerprintScanner : IDisposable
_registrationExpiresAtUtc = default;
if (result == 0)
{
RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
StatusChanged?.Invoke("Отпечаток успешно зарегистрирован.");
var templateBase64 = zkfp2.BlobToBase64(merged, mergedLength);
RegistrationCompleted?.Invoke(fingerprintId, templateBase64);
SetState(ScannerState.Connected, "Отпечаток успешно зарегистрирован.");
CrashLogService.WriteDiagnostic($"Fingerprint id {fingerprintId} successfully merged and enrolled.");
}
else
{
StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию.");
SetState(ScannerState.Connected, $"Не удалось объединить сканы (код {result}). Повторите регистрацию.");
CrashLogService.WriteDiagnostic($"Fingerprint enrollment merge failed: code {result}");
}
return;
}
@ -263,11 +517,18 @@ public sealed class FingerprintScanner : IDisposable
}
else
{
StatusChanged?.Invoke("Отпечаток не распознан.");
SetState(ScannerState.Connected, "Отпечаток не распознан.");
}
}
}
private void SetState(ScannerState newState, string message)
{
State = newState;
StatusChanged?.Invoke(message);
StateChanged?.Invoke(newState, message);
}
public void Disconnect()
{
_captureCancellation?.Cancel();
@ -288,7 +549,7 @@ public sealed class FingerprintScanner : IDisposable
TaskScheduler.Default);
}
throw new TimeoutException(
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения.");
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены после завершения операции.");
}
}
catch (AggregateException exception)
@ -318,11 +579,63 @@ public sealed class FingerprintScanner : IDisposable
}
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();