From c479bb60caf446c404e1413af7cac73a577e2fbd Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:20:39 +0700 Subject: [PATCH] feat(scanner): add IFingerprintScanner, deduplication, auto-reconnect, and state machine --- .../Services/CrashLogService.cs | 21 +- .../Services/FingerprintScanner.cs | 393 ++++++++++++++++-- 2 files changed, 373 insertions(+), 41 deletions(-) diff --git a/apps/KronaStaffTerminal/Services/CrashLogService.cs b/apps/KronaStaffTerminal/Services/CrashLogService.cs index b2bc64d..a38996e 100644 --- a/apps/KronaStaffTerminal/Services/CrashLogService.cs +++ b/apps/KronaStaffTerminal/Services/CrashLogService.cs @@ -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; diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs index 532fe88..d2015bf 100644 --- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs +++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs @@ -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 DiscoveredDevices { get; } + bool IsConnected { get; } + int SkippedTemplateCount { get; } + + event Action? FingerRecognized; + event Action? RegistrationProgress; + event Action? RegistrationCompleted; + event Action? StatusChanged; + event Action? StateChanged; + event Action? ConnectionLost; + event Action? RegistrationCancelled; + + IReadOnlyList 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,63 +56,198 @@ public sealed class FingerprintScanner : IDisposable private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private bool _fingerOnSensor; private int _cleanupScheduled; + private List _discoveredDevices = []; + private List<(int FingerprintId, string Template)> _lastLoadedTemplates = []; + private CancellationTokenSource? _reconnectCancellation; + private Task? _reconnectTask; + private bool _autoReconnectEnabled = true; public event Action? FingerRecognized; public event Action? RegistrationProgress; + event Action? IFingerprintScanner.RegistrationCompleted + { + add => RegistrationCompleted += value; + remove => RegistrationCompleted -= value; + } public event Action? RegistrationCompleted; public event Action? StatusChanged; + public event Action? 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 DiscoveredDevices => _discoveredDevices; + public bool IsConnected => _device != IntPtr.Zero && State == ScannerState.Connected; + + public IReadOnlyList DiscoverDevices() + { + lock (_gate) + { + var devices = new List(); + var seenSerials = new HashSet(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) + if (result != 0 && result != 1) { - throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result})."); + SetState(ScannerState.DriverError, $"Драйвер сканера вернул ошибку инициализации (код {result})."); + CrashLogService.WriteDiagnostic($"Scanner initialization failed: code {result}"); + StartReconnectLoop(); + throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}). Проверьте драйвер."); } - // 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) + var available = DiscoverDevices(); + if (available.Count == 0) { zkfp2.Terminate(); - throw new InvalidOperationException("Подключённые сканеры не найдены."); + SetState(ScannerState.Disconnected, "Подключённые сканеры не найдены."); + CrashLogService.WriteDiagnostic("Scanner connect failed: no devices found."); + StartReconnectLoop(); + throw new InvalidOperationException("Подключённые сканеры не найдены. Подключите USB-сканер."); } - 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) - { - var candidateDevice = zkfp2.OpenDevice(candidateIndex); - if (candidateDevice == IntPtr.Zero) continue; - ReaderIndex = candidateIndex; - _device = candidateDevice; - break; - } - if (_device == IntPtr.Zero) + 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(); - throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель."); + 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(); - 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("Ошибка сканера. Выполняется переподключение…"); - ConnectionLost?.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();