using System.IO.Ports; using System.Text; using System.Text.RegularExpressions; namespace Housing.Services; public sealed class BarcodeScanReceivedEventArgs : EventArgs { public BarcodeScanReceivedEventArgs(string portName, string text, bool isConfirmedPort) { PortName = portName; Text = text; IsConfirmedPort = isConfirmedPort; } public string PortName { get; } public string Text { get; } public bool IsConfirmedPort { get; } } public sealed class SerialBarcodeScanner : IDisposable { private readonly BarcodeScannerSettings _settings; private readonly HashSet _excludedPortNames; private readonly List _sessions = new(); private readonly object _sync = new(); private bool _disposed; private string? _confirmedPortName; public SerialBarcodeScanner(BarcodeScannerSettings settings, IEnumerable excludedPortNames) { _settings = settings; _excludedPortNames = excludedPortNames .Where(portName => !string.IsNullOrWhiteSpace(portName)) .Select(NormalizePortName) .ToHashSet(StringComparer.OrdinalIgnoreCase); } public event EventHandler? ScanReceived; public string? ConfirmedPortName { get { lock (_sync) { return _confirmedPortName; } } } public void Start() { ThrowIfDisposed(); var candidatePortNames = GetCandidatePortNames(); if (candidatePortNames.Length == 0) { throw new InvalidOperationException("No available COM ports for the barcode scanner."); } var openErrors = new List(); foreach (var portName in candidatePortNames) { PortSession? session = null; try { var port = CreateSerialPort(portName); session = new PortSession(port, CommitBufferedScan); port.DataReceived += SerialPort_DataReceived; lock (_sync) { _sessions.Add(session); } port.Open(); port.DiscardInBuffer(); port.DiscardOutBuffer(); } catch (Exception ex) { if (session is not null) { lock (_sync) { _sessions.Remove(session); } CloseSession(session); } openErrors.Add($"{portName}: {ex.Message}"); } } int sessionCount; lock (_sync) { sessionCount = _sessions.Count; } if (sessionCount == 0) { var detail = openErrors.Count == 0 ? string.Empty : $" ({string.Join("; ", openErrors)})"; throw new InvalidOperationException($"Could not open a barcode scanner COM port.{detail}"); } if (!BarcodeScannerSettings.IsAutoPort(_settings.PortName) || sessionCount == 1) { lock (_sync) { _confirmedPortName = _sessions[0].Port.PortName; } } } public void ConfirmPort(string portName) { if (string.IsNullOrWhiteSpace(portName)) { return; } List sessionsToClose; lock (_sync) { if (_disposed) { return; } var normalizedPortName = NormalizePortName(portName); if (!string.IsNullOrWhiteSpace(_confirmedPortName) && !string.Equals(_confirmedPortName, normalizedPortName, StringComparison.OrdinalIgnoreCase)) { return; } var confirmedSession = _sessions.FirstOrDefault(session => string.Equals(session.Port.PortName, normalizedPortName, StringComparison.OrdinalIgnoreCase)); if (confirmedSession is null) { return; } _confirmedPortName = confirmedSession.Port.PortName; sessionsToClose = _sessions .Where(session => !ReferenceEquals(session, confirmedSession)) .ToList(); _sessions.RemoveAll(session => !ReferenceEquals(session, confirmedSession)); } foreach (var session in sessionsToClose) { CloseSession(session); } } private SerialPort CreateSerialPort(string portName) { return new SerialPort(portName, _settings.BaudRate) { Encoding = Encoding.ASCII, NewLine = "\r\n", ReadTimeout = _settings.ReadTimeout, WriteTimeout = _settings.ReadTimeout, DtrEnable = _settings.DtrEnable, RtsEnable = _settings.RtsEnable }; } private string[] GetCandidatePortNames() { if (!BarcodeScannerSettings.IsAutoPort(_settings.PortName)) { return new[] { NormalizePortName(_settings.PortName) }; } return SerialPort.GetPortNames() .Select(NormalizePortName) .Where(portName => !_excludedPortNames.Contains(portName)) .OrderBy(GetPortNumber) .ThenBy(portName => portName, StringComparer.OrdinalIgnoreCase) .ToArray(); } private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { if (sender is not SerialPort port) { return; } PortSession? session; lock (_sync) { if (_disposed) { return; } session = _sessions.FirstOrDefault(item => ReferenceEquals(item.Port, port)); } if (session is null) { return; } string text; try { text = port.ReadExisting(); } catch { return; } if (string.IsNullOrEmpty(text)) { return; } var scans = AppendReceivedText(session, text); foreach (var scan in scans) { PublishScan(session, scan); } } private List AppendReceivedText(PortSession session, string text) { lock (session.Sync) { session.Buffer.Append(text); var scans = DrainCompleteScans(session.Buffer); if (session.Buffer.Length > 0) { var delay = Math.Max(50, _settings.IdleCommitMilliseconds); session.CommitTimer.Change(delay, Timeout.Infinite); } else { session.CommitTimer.Change(Timeout.Infinite, Timeout.Infinite); } return scans; } } private void CommitBufferedScan(PortSession session) { string scan; lock (session.Sync) { scan = NormalizeScanText(session.Buffer.ToString()); session.Buffer.Clear(); session.CommitTimer.Change(Timeout.Infinite, Timeout.Infinite); } PublishScan(session, scan); } private void PublishScan(PortSession session, string scan) { scan = NormalizeScanText(scan); if (string.IsNullOrWhiteSpace(scan)) { return; } bool isConfirmedPort; lock (_sync) { if (_disposed || !_sessions.Contains(session)) { return; } isConfirmedPort = string.Equals( _confirmedPortName, session.Port.PortName, StringComparison.OrdinalIgnoreCase); } ScanReceived?.Invoke(this, new BarcodeScanReceivedEventArgs(session.Port.PortName, scan, isConfirmedPort)); } private static List DrainCompleteScans(StringBuilder buffer) { var scans = new List(); var text = buffer.ToString(); var start = 0; for (var index = 0; index < text.Length; index++) { if (text[index] != '\r' && text[index] != '\n') { continue; } var line = NormalizeScanText(text[start..index]); if (line.Length > 0) { scans.Add(line); } if (text[index] == '\r' && index + 1 < text.Length && text[index + 1] == '\n') { index++; } start = index + 1; } if (start > 0) { buffer.Clear(); if (start < text.Length) { buffer.Append(text[start..]); } } return scans; } private static string NormalizeScanText(string text) { if (string.IsNullOrEmpty(text)) { return string.Empty; } var builder = new StringBuilder(text.Length); foreach (var character in text.Trim()) { if (!char.IsControl(character)) { builder.Append(character); } } return builder.ToString().Trim(); } private static string NormalizePortName(string portName) { return portName.Trim().ToUpperInvariant(); } private static int GetPortNumber(string portName) { var match = Regex.Match(portName, @"\d+"); return match.Success && int.TryParse(match.Value, out var number) ? number : int.MaxValue; } private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } private void CloseSession(PortSession session) { try { session.Port.DataReceived -= SerialPort_DataReceived; } catch { // Best effort during shutdown. } session.Dispose(); } public void Dispose() { List sessionsToClose; lock (_sync) { if (_disposed) { return; } _disposed = true; sessionsToClose = _sessions.ToList(); _sessions.Clear(); } foreach (var session in sessionsToClose) { CloseSession(session); } } private sealed class PortSession : IDisposable { private readonly Action _commitBufferedScan; public PortSession(SerialPort port, Action commitBufferedScan) { Port = port; _commitBufferedScan = commitBufferedScan; CommitTimer = new Timer(_ => _commitBufferedScan(this), null, Timeout.Infinite, Timeout.Infinite); } public SerialPort Port { get; } public StringBuilder Buffer { get; } = new(); public object Sync { get; } = new(); public Timer CommitTimer { get; } public void Dispose() { CommitTimer.Dispose(); Port.Dispose(); } } }