Files
Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs
yunxiao.zhu 49f113dcf3 feat: 初始化 PCB 检测 WPF 应用程序
* 创建 AxiOmron.PcbCheck 项目主框架及解决方案
* 添加 Dashboard 和系统设置页面
* 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务
* 集成 Andon 报警、工作流托管服务与日志配置
* 补充项目文档和 UI 设计规范
2026-04-17 10:43:51 +08:00

320 lines
10 KiB
C#

using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using System.Text;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Utils;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供串口扫码枪的触发与读取能力。
/// </summary>
public sealed class SerialScannerService : IScannerService, IDisposable
{
private readonly ScannerOptions _options;
private readonly IAppLogger<SerialScannerService> _logger;
private readonly SemaphoreSlim _ioLock = new(1, 1);
private SerialPort? _serialPort;
private bool _disposed;
/// <summary>
/// 初始化扫码枪服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public SerialScannerService(AppConfig config, IAppLogger<SerialScannerService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Scanner;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 触发一次扫码。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
public async Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken)
{
var lockTaken = false;
try
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
lockTaken = true;
return await TriggerScanInternalAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "扫码操作失败");
return new ScanOperationResult
{
IsSuccess = false,
IsSystemError = ex is not OperationCanceledException,
DeviceConnected = false,
ErrorMessage = ex.Message
};
}
finally
{
if (lockTaken)
{
_ioLock.Release();
}
}
}
/// <summary>
/// 测试扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>连接正常返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken)
{
var lockTaken = false;
try
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
lockTaken = true;
cancellationToken.ThrowIfCancellationRequested();
EnsurePortOpen();
return _serialPort is { IsOpen: true };
}
catch (Exception ex)
{
_logger.LogWarning(ex, "扫码枪连接测试失败");
ClosePortUnsafe();
return false;
}
finally
{
if (lockTaken)
{
_ioLock.Release();
}
}
}
/// <summary>
/// 主动断开并重建扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
public async Task ForceReconnectAsync(CancellationToken cancellationToken)
{
var lockTaken = false;
try
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
lockTaken = true;
cancellationToken.ThrowIfCancellationRequested();
ClosePortUnsafe();
EnsurePortOpen();
}
catch (Exception ex)
{
_logger.LogError(ex, "扫码枪强制重连失败");
ClosePortUnsafe();
}
finally
{
if (lockTaken)
{
_ioLock.Release();
}
}
}
/// <summary>
/// 释放扫码枪串口资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
ClosePortUnsafe();
_ioLock.Dispose();
}
/// <summary>
/// 在异步上下文中执行一次扫码流程。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
private async Task<ScanOperationResult> TriggerScanInternalAsync(CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
EnsurePortOpen();
var port = _serialPort ?? throw new InvalidOperationException("扫码枪串口尚未初始化。");
port.DiscardInBuffer();
port.DiscardOutBuffer();
port.Write(StringEscapeHelper.Unescape(_options.TriggerCommand));
var rawMessage = await ReadUntilTerminatorAsync(
port,
StringEscapeHelper.Unescape(_options.ResponseTerminator),
_options.ReadTimeoutMs,
cancellationToken).ConfigureAwait(false);
var barcode = BarcodeCleaner.Clean(rawMessage);
if (string.IsNullOrEmpty(barcode))
{
return new ScanOperationResult
{
IsSuccess = false,
DeviceConnected = true,
RawMessage = rawMessage,
ErrorMessage = "扫码返回空字符串或仅包含控制字符。",
DurationMs = stopwatch.ElapsedMilliseconds
};
}
return new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = barcode,
RawMessage = rawMessage,
DurationMs = stopwatch.ElapsedMilliseconds
};
}
catch (TimeoutException ex)
{
_logger.LogWarning(ex, "扫码等待超时");
return new ScanOperationResult
{
IsSuccess = false,
DeviceConnected = true,
ErrorMessage = "扫码超时。",
DurationMs = stopwatch.ElapsedMilliseconds
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "扫码枪执行失败");
ClosePortUnsafe();
return new ScanOperationResult
{
IsSuccess = false,
IsSystemError = true,
DeviceConnected = false,
ErrorMessage = ex.Message,
DurationMs = stopwatch.ElapsedMilliseconds
};
}
}
/// <summary>
/// 确保串口已打开并按当前配置初始化。
/// </summary>
private void EnsurePortOpen()
{
if (_serialPort is { IsOpen: true })
{
return;
}
ClosePortUnsafe();
var availablePorts = SerialPort.GetPortNames();
if (!availablePorts.Contains(_options.PortName, StringComparer.OrdinalIgnoreCase))
{
_logger.LogWarning("本地不存在串口 {PortName},可用串口: {AvailablePorts}", false, _options.PortName, string.Join(", ", availablePorts));
throw new IOException($"串口 {_options.PortName} 不存在。可用串口: {string.Join(", ", availablePorts)}");
}
var parity = Enum.Parse<Parity>(_options.Parity, true);
var stopBits = Enum.Parse<StopBits>(_options.StopBits, true);
var serialPort = new SerialPort(_options.PortName, _options.BaudRate, parity, _options.DataBits, stopBits)
{
ReadTimeout = 200,
WriteTimeout = 1000,
Encoding = Encoding.ASCII,
DtrEnable = true,
RtsEnable = true,
NewLine = StringEscapeHelper.Unescape(_options.ResponseTerminator)
};
try
{
serialPort.Open();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "打开串口 {PortName} 失败", false, _options.PortName);
serialPort.Dispose();
throw;
}
_serialPort = serialPort;
_logger.LogInformation("已连接扫码枪串口 {PortName}", false, _options.PortName);
}
/// <summary>
/// 从串口读取直到遇到终止符或超时。
/// </summary>
/// <param name="port">串口实例。</param>
/// <param name="terminator">终止符。</param>
/// <param name="timeoutMs">总超时时间,单位为毫秒。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>读取到的原始字符串。</returns>
private static async Task<string> ReadUntilTerminatorAsync(SerialPort port, string terminator, int timeoutMs, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var buffer = new StringBuilder();
while (stopwatch.ElapsedMilliseconds < timeoutMs)
{
cancellationToken.ThrowIfCancellationRequested();
var fragment = port.ReadExisting();
if (!string.IsNullOrEmpty(fragment))
{
buffer.Append(fragment);
if (string.IsNullOrEmpty(terminator) || buffer.ToString().Contains(terminator, StringComparison.Ordinal))
{
return buffer.ToString();
}
}
await Task.Delay(20, cancellationToken).ConfigureAwait(false);
}
throw new TimeoutException("扫码枪在规定时间内未返回完整报文。");
}
/// <summary>
/// 关闭当前串口并释放资源。
/// </summary>
private void ClosePortUnsafe()
{
try
{
if (_serialPort?.IsOpen == true)
{
_serialPort.Close();
}
}
catch
{
// 忽略关闭异常。
}
finally
{
_serialPort?.Dispose();
_serialPort = null;
}
}
}