You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

427 lines
11 KiB

2 months ago
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<string> _excludedPortNames;
private readonly List<PortSession> _sessions = new();
private readonly object _sync = new();
private bool _disposed;
private string? _confirmedPortName;
public SerialBarcodeScanner(BarcodeScannerSettings settings, IEnumerable<string> excludedPortNames)
{
_settings = settings;
_excludedPortNames = excludedPortNames
.Where(portName => !string.IsNullOrWhiteSpace(portName))
.Select(NormalizePortName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
public event EventHandler<BarcodeScanReceivedEventArgs>? 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<string>();
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<PortSession> 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<string> 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<string> DrainCompleteScans(StringBuilder buffer)
{
var scans = new List<string>();
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<PortSession> 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<PortSession> _commitBufferedScan;
public PortSession(SerialPort port, Action<PortSession> 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();
}
}
}