feat: 初始化 PCB 检测 WPF 应用程序

* 创建 AxiOmron.PcbCheck 项目主框架及解决方案
* 添加 Dashboard 和系统设置页面
* 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务
* 集成 Andon 报警、工作流托管服务与日志配置
* 补充项目文档和 UI 设计规范
This commit is contained in:
2026-04-17 10:43:51 +08:00
parent 660ee99442
commit 49f113dcf3
46 changed files with 8042 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
using System.Net.Http;
using System.Net.Http.Json;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供安灯 HTTP 接口调用能力。
/// </summary>
public sealed class AndonService : IAndonService
{
private readonly AndonOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IAppLogger<AndonService> _logger;
/// <summary>
/// 初始化安灯服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="httpClientFactory">HttpClient 工厂。</param>
/// <param name="logger">日志记录器。</param>
public AndonService(AppConfig config, IHttpClientFactory httpClientFactory, IAppLogger<AndonService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Andon;
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 发送安灯报警。
/// </summary>
/// <param name="request">报警请求对象。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>报警调用结果。</returns>
public async Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!_options.Enable || string.IsNullOrWhiteSpace(_options.Url))
{
return new AndonOperationResult
{
IsSuccess = false,
EndpointReached = false,
ErrorMessage = "安灯接口未启用或 URL 未配置。"
};
}
try
{
using var client = _httpClientFactory.CreateClient(nameof(AndonService));
client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url)
{
Content = JsonContent.Create(new
{
stationCode = _options.StationCode,
stationName = _options.StationName,
alarmType = request.AlarmType,
alarmCode = request.AlarmCode,
alarmMessage = request.AlarmMessage,
barcode = request.Barcode,
triggeredAt = request.TriggeredAt,
machineName = Environment.MachineName
})
};
foreach (var header in _options.Headers)
{
message.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new AndonOperationResult
{
IsSuccess = response.IsSuccessStatusCode,
EndpointReached = true,
StatusCode = (int)response.StatusCode,
ResponseBody = body,
ErrorMessage = response.IsSuccessStatusCode ? string.Empty : $"HTTP {(int)response.StatusCode}: {body}"
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "安灯接口调用失败");
return new AndonOperationResult
{
IsSuccess = false,
EndpointReached = false,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 发送一次测试报警请求。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>测试调用结果。</returns>
public Task<AndonOperationResult> TestAsync(CancellationToken cancellationToken)
{
return RaiseAlarmAsync(new AndonAlarmRequest
{
AlarmType = "ManualTest",
AlarmCode = (ushort)AlarmCode.ScanFailed,
AlarmMessage = "手动测试安灯接口",
Barcode = string.Empty,
TriggeredAt = DateTimeOffset.Now
}, cancellationToken);
}
}

View File

@@ -0,0 +1,65 @@
using System.IO;
using System.Text.Json;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供应用配置文件的读取与保存能力。
/// </summary>
public sealed class AppConfigService : IAppConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
/// <summary>
/// 读取当前应用配置。
/// </summary>
/// <returns>根配置对象。</returns>
public AppConfig Load()
{
var configPath = GetConfigPath();
if (!File.Exists(configPath))
{
var defaultConfig = new AppConfig();
Save(defaultConfig);
return defaultConfig;
}
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json, JsonOptions);
return config ?? new AppConfig();
}
/// <summary>
/// 保存当前应用配置。
/// </summary>
/// <param name="config">待保存的配置对象。</param>
public void Save(AppConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var configPath = GetConfigPath();
var directory = Path.GetDirectoryName(configPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(configPath, json);
}
/// <summary>
/// 获取主配置文件路径。
/// </summary>
/// <returns>配置文件绝对路径。</returns>
public string GetConfigPath()
{
return Path.Combine(AppContext.BaseDirectory, "appConfig.json");
}
}

View File

@@ -0,0 +1,154 @@
using System.Globalization;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供应用统一日志能力,并在需要时同步前台 UI 日志。
/// </summary>
/// <typeparam name="TCategoryName">日志分类类型。</typeparam>
public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
{
private readonly ILogger<TCategoryName> _logger;
private readonly IAppStateStore _stateStore;
/// <summary>
/// 初始化统一日志服务。
/// </summary>
/// <param name="logger">底层标准日志记录器。</param>
/// <param name="stateStore">运行态存储。</param>
public AppLogger(ILogger<TCategoryName> logger, IAppStateStore stateStore)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
}
/// <summary>
/// 记录一条信息日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogInformation(string message, bool showInUi = false, params object?[] args)
{
_logger.LogInformation(message, args);
PublishUiLog(LogLevel.Information, message, null, showInUi, args);
}
/// <summary>
/// 记录一条警告日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogWarning(string message, bool showInUi = false, params object?[] args)
{
_logger.LogWarning(message, args);
PublishUiLog(LogLevel.Warning, message, null, showInUi, args);
}
/// <summary>
/// 记录一条带异常的警告日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogWarning(exception, message, args);
PublishUiLog(LogLevel.Warning, message, exception, showInUi, args);
}
/// <summary>
/// 记录一条错误日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogError(string message, bool showInUi = false, params object?[] args)
{
_logger.LogError(message, args);
PublishUiLog(LogLevel.Error, message, null, showInUi, args);
}
/// <summary>
/// 记录一条带异常的错误日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogError(exception, message, args);
PublishUiLog(LogLevel.Error, message, exception, showInUi, args);
}
/// <summary>
/// 按需向前台运行态发布日志。
/// </summary>
/// <param name="logLevel">日志级别。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="exception">异常对象。</param>
/// <param name="showInUi">是否显示到前台。</param>
/// <param name="args">日志模板参数。</param>
private void PublishUiLog(LogLevel logLevel, string message, Exception? exception, bool showInUi, params object?[] args)
{
if (!showInUi)
{
return;
}
var formattedMessage = FormatMessage(message, args);
if (exception is not null)
{
formattedMessage = string.IsNullOrWhiteSpace(formattedMessage)
? exception.Message
: $"{formattedMessage}: {exception.Message}";
}
_stateStore.AddLog(new UiLogEntry
{
Level = logLevel.ToString(),
Message = formattedMessage,
Timestamp = DateTimeOffset.Now
});
}
/// <summary>
/// 将日志模板与参数格式化为可展示文本。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="args">日志模板参数。</param>
/// <returns>格式化后的文本。</returns>
private static string FormatMessage(string message, params object?[] args)
{
if (args.Length == 0)
{
return message;
}
var formattedMessage = message;
for (var index = 0; index < args.Length; index++)
{
var replacement = Convert.ToString(args[index], CultureInfo.InvariantCulture) ?? string.Empty;
var tokenStart = formattedMessage.IndexOf('{', StringComparison.Ordinal);
var tokenEnd = tokenStart >= 0 ? formattedMessage.IndexOf('}', tokenStart + 1) : -1;
if (tokenStart < 0 || tokenEnd <= tokenStart)
{
break;
}
formattedMessage = formattedMessage.Remove(tokenStart, tokenEnd - tokenStart + 1).Insert(tokenStart, replacement);
}
return formattedMessage;
}
}

View File

@@ -0,0 +1,79 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供运行态快照、日志与单板记录的线程安全存储能力。
/// </summary>
public sealed class AppStateStore : IAppStateStore
{
private readonly object _syncRoot = new();
private RuntimeSnapshot _snapshot = new();
/// <summary>
/// 当运行态快照变化时触发。
/// </summary>
public event EventHandler<RuntimeSnapshot>? SnapshotChanged;
/// <summary>
/// 当新增日志时触发。
/// </summary>
public event EventHandler<UiLogEntry>? LogAdded;
/// <summary>
/// 当新增单板记录时触发。
/// </summary>
public event EventHandler<BoardProcessRecord>? RecordAdded;
/// <summary>
/// 获取当前运行态快照副本。
/// </summary>
/// <returns>当前快照副本。</returns>
public RuntimeSnapshot GetSnapshot()
{
lock (_syncRoot)
{
return _snapshot.Clone();
}
}
/// <summary>
/// 更新当前运行态快照。
/// </summary>
/// <param name="updateAction">用于修改快照的更新委托。</param>
public void UpdateSnapshot(Action<RuntimeSnapshot> updateAction)
{
ArgumentNullException.ThrowIfNull(updateAction);
RuntimeSnapshot clonedSnapshot;
lock (_syncRoot)
{
updateAction(_snapshot);
_snapshot.LastUpdatedAt = DateTimeOffset.Now;
clonedSnapshot = _snapshot.Clone();
}
SnapshotChanged?.Invoke(this, clonedSnapshot);
}
/// <summary>
/// 追加一条 UI 日志。
/// </summary>
/// <param name="entry">待追加的日志对象。</param>
public void AddLog(UiLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
LogAdded?.Invoke(this, entry);
}
/// <summary>
/// 追加一条单板结果记录。
/// </summary>
/// <param name="record">待追加的记录对象。</param>
public void AddRecord(BoardProcessRecord record)
{
ArgumentNullException.ThrowIfNull(record);
RecordAdded?.Invoke(this, record);
}
}

View File

@@ -0,0 +1,23 @@
using System.Windows.Threading;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供切回 WPF UI 线程的调度能力。
/// </summary>
public sealed class DispatcherService : IDispatcherService
{
/// <summary>
/// 在 UI 线程中执行指定动作。
/// </summary>
/// <param name="action">待执行的动作。</param>
/// <returns>表示调度完成的任务。</returns>
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
var dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
return dispatcher.InvokeAsync(action).Task;
}
}

View File

@@ -0,0 +1,257 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
using IoTClient.Clients.Modbus;
using IoTClient.Enums;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供基于 IoTClient ModbusTcpClient 的 PLC 读写能力。
/// </summary>
public sealed class ModbusTcpPlcService : IPlcService, IDisposable
{
private readonly PlcOptions _options;
private readonly IAppLogger<ModbusTcpPlcService> _logger;
private readonly SemaphoreSlim _ioLock = new(1, 1);
private ModbusTcpClient? _client;
private PlcProcessState? _lastWrittenState;
private bool _disposed;
/// <summary>
/// 初始化 PLC 通信服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public ModbusTcpPlcService(AppConfig config, IAppLogger<ModbusTcpPlcService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Plc;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 读取 PLC 输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>输入信号快照。</returns>
public async Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
EnsureConnected();
return new PlcSignalSnapshot
{
PlcReady = ReadDiscrete(_options.Inputs.PlcReady),
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
AutoMode = ReadDiscrete(_options.Inputs.AutoMode),
StationEnable = ReadDiscrete(_options.Inputs.StationEnable),
CapturedAt = DateTimeOffset.Now
};
}
catch
{
DisconnectUnsafe();
throw;
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 写入 PLC 输出状态与寄存器值。
/// </summary>
/// <param name="state">待写入的输出状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示写入完成的任务。</returns>
public async Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
EnsureConnected();
WriteChangedState(state);
_lastWrittenState = state.Clone();
}
catch
{
DisconnectUnsafe();
throw;
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 主动断开并重建 PLC 连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
public async Task ForceReconnectAsync(CancellationToken cancellationToken)
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
DisconnectUnsafe();
_lastWrittenState = null;
EnsureConnected();
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 释放 PLC 通信资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
DisconnectUnsafe();
_ioLock.Dispose();
}
/// <summary>
/// 确保 IoTClient Modbus TCP 客户端已连接。
/// </summary>
private void EnsureConnected()
{
if (_client is { Connected: true })
{
return;
}
DisconnectUnsafe();
var client = new ModbusTcpClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD, false);
var openResult = client.Open();
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 PLC 失败");
_client = client;
_logger.LogInformation("已通过 IoTClient 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
}
/// <summary>
/// 读取单个离散输入位。
/// </summary>
/// <param name="address">离散输入地址。</param>
/// <returns>读取到的布尔值。</returns>
private bool ReadDiscrete(int address)
{
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.ReadDiscrete(address, _options.UnitId, 2);
EnsureSuccess(result.IsSucceed, result.Err, $"读取离散输入失败,地址={address}");
return result.Value;
}
/// <summary>
/// 写入所有发生变化的线圈位与寄存器。
/// </summary>
/// <param name="state">目标状态。</param>
private void WriteChangedState(PlcProcessState state)
{
var previous = _lastWrittenState;
WriteSingleCoilIfChanged(previous?.PcOnline, state.PcOnline, _options.Outputs.PcOnline);
WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy);
WriteSingleCoilIfChanged(previous?.ScanOk, state.ScanOk, _options.Outputs.ScanOk);
WriteSingleCoilIfChanged(previous?.ScanNg, state.ScanNg, _options.Outputs.ScanNg);
WriteSingleCoilIfChanged(previous?.FileFound, state.FileFound, _options.Outputs.FileFound);
WriteSingleCoilIfChanged(previous?.FileNotFound, state.FileNotFound, _options.Outputs.FileNotFound);
WriteSingleCoilIfChanged(previous?.AlarmRaised, state.AlarmRaised, _options.Outputs.AlarmRaised);
WriteSingleCoilIfChanged(previous?.ReleasePermit, state.ReleasePermit, _options.Outputs.ReleasePermit);
WriteSingleCoilIfChanged(previous?.ProcessDone, state.ProcessDone, _options.Outputs.ProcessDone);
WriteSingleCoilIfChanged(previous?.SystemFault, state.SystemFault, _options.Outputs.SystemFault);
WriteSingleRegisterIfChanged(previous?.ResultCode, state.ResultCode, _options.Registers.ResultCode);
WriteSingleRegisterIfChanged(previous?.ScanTryCount, state.ScanTryCount, _options.Registers.ScanTryCount);
WriteSingleRegisterIfChanged(previous?.SftpTryCount, state.SftpTryCount, _options.Registers.SftpTryCount);
WriteSingleRegisterIfChanged(previous?.AlarmCode, state.AlarmCode, _options.Registers.AlarmCode);
WriteSingleRegisterIfChanged(previous?.FlowStateCode, state.FlowStateCode, _options.Registers.FlowStateCode);
}
/// <summary>
/// 仅当值发生变化时写入单个线圈位。
/// </summary>
/// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param>
/// <param name="address">线圈地址。</param>
private void WriteSingleCoilIfChanged(bool? previous, bool current, int address)
{
if (previous.HasValue && previous.Value == current)
{
return;
}
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.Write(address.ToString(), current, _options.UnitId, 5);
EnsureSuccess(result.IsSucceed, result.Err, $"写入线圈失败,地址={address},值={current}");
}
/// <summary>
/// 仅当值发生变化时写入单个保持寄存器。
/// </summary>
/// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param>
/// <param name="address">保持寄存器地址。</param>
private void WriteSingleRegisterIfChanged(ushort? previous, ushort current, int address)
{
if (previous.HasValue && previous.Value == current)
{
return;
}
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.Write(address.ToString(), current, _options.UnitId, 6);
EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}");
}
/// <summary>
/// 校验 IoTClient 调用结果是否成功。
/// </summary>
/// <param name="isSucceed">调用是否成功。</param>
/// <param name="error">错误消息。</param>
/// <param name="message">异常消息前缀。</param>
private static void EnsureSuccess(bool isSucceed, string? error, string message)
{
if (!isSucceed)
{
throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? message : $"{message}: {error}");
}
}
/// <summary>
/// 断开当前 PLC 客户端连接。
/// </summary>
private void DisconnectUnsafe()
{
try
{
_client?.Close();
}
catch
{
// 忽略关闭异常。
}
finally
{
_client = null;
}
}
}

View File

@@ -0,0 +1,319 @@
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;
}
}
}

View File

@@ -0,0 +1,200 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Utils;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供 SFTP 文件存在性校验能力。
/// </summary>
public sealed class SftpLookupService : ISftpLookupService
{
private readonly SftpOptions _options;
private readonly IAppLogger<SftpLookupService> _logger;
/// <summary>
/// 初始化 SFTP 校验服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public SftpLookupService(AppConfig config, IAppLogger<SftpLookupService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Sftp;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 按条码检查目标文件是否存在。
/// </summary>
/// <param name="barcode">条码内容。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件校验结果。</returns>
public async Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(barcode))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ErrorMessage = "条码为空,无法执行 SFTP 查询。"
};
}
if (string.IsNullOrWhiteSpace(_options.Host) || string.IsNullOrWhiteSpace(_options.RootPath))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ErrorMessage = "SFTP 配置缺失 Host 或 RootPath。"
};
}
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 在同步上下文中执行 SFTP 查询。
/// </summary>
/// <param name="barcode">条码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>查询结果。</returns>
private SftpCheckOutcome CheckInternal(string barcode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
using var client = CreateClient();
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs);
client.Connect();
if (!client.IsConnected)
{
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = "SFTP 未能建立连接。"
};
}
var candidateName = BuildExpectedFileName(barcode);
var rootPath = NormalizeDirectory(_options.RootPath);
if (!client.Exists(rootPath))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ConnectionSucceeded = true,
ErrorMessage = $"SFTP 根目录不存在: {rootPath}"
};
}
var matched = client.ListDirectory(rootPath)
.Where(entry => !entry.IsDirectory && !entry.IsSymbolicLink)
.FirstOrDefault(entry =>
entry.Name.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
|| WildcardMatcher.IsMatch(entry.Name, candidateName)
|| entry.Name.Contains(barcode, StringComparison.OrdinalIgnoreCase));
if (matched is null)
{
return new SftpCheckOutcome
{
Exists = false,
ConnectionSucceeded = true,
ErrorMessage = $"未找到与条码 {barcode} 匹配的文件。"
};
}
return new SftpCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = matched.FullName
};
}
catch (OperationCanceledException)
{
throw;
}
catch (SshAuthenticationException ex)
{
_logger.LogError(ex, "SFTP 认证失败");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = $"SFTP 认证失败: {ex.Message}"
};
}
catch (SshConnectionException ex)
{
_logger.LogError(ex, "SFTP 连接失败");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = $"SFTP 连接失败: {ex.Message}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "SFTP 查询异常");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 根据当前配置创建 SFTP 客户端。
/// </summary>
/// <returns>SFTP 客户端实例。</returns>
private SftpClient CreateClient()
{
if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
{
var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase)
? new PrivateKeyFile(_options.PrivateKeyPath)
: new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase);
var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile);
var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth);
return new SftpClient(connectionInfo);
}
return new SftpClient(_options.Host, _options.Port, _options.Username, _options.Password);
}
/// <summary>
/// 根据条码和模板构建预期文件名。
/// </summary>
/// <param name="barcode">条码。</param>
/// <returns>预期文件名或匹配模式。</returns>
private string BuildExpectedFileName(string barcode)
{
var pattern = string.IsNullOrWhiteSpace(_options.FileNamePattern) ? "${barcode}.txt" : _options.FileNamePattern;
return pattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 统一目录路径格式。
/// </summary>
/// <param name="path">原始目录路径。</param>
/// <returns>标准化目录路径。</returns>
private static string NormalizeDirectory(string path)
{
return path.Replace('\\', '/').TrimEnd('/');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
namespace AxiOmron.PcbCheck.Services.Interfaces;
/// <summary>
/// 定义应用配置文件读写能力。
/// </summary>
public interface IAppConfigService
{
/// <summary>
/// 读取当前应用配置。
/// </summary>
/// <returns>根配置对象。</returns>
AppConfig Load();
/// <summary>
/// 保存当前应用配置。
/// </summary>
/// <param name="config">待保存的配置对象。</param>
void Save(AppConfig config);
/// <summary>
/// 获取主配置文件路径。
/// </summary>
/// <returns>配置文件绝对路径。</returns>
string GetConfigPath();
}
/// <summary>
/// 定义 WPF Dispatcher 调度能力。
/// </summary>
public interface IDispatcherService
{
/// <summary>
/// 在 UI 线程中执行指定动作。
/// </summary>
/// <param name="action">待执行的动作。</param>
/// <returns>表示调度完成的任务。</returns>
Task InvokeAsync(Action action);
}
/// <summary>
/// 定义应用统一日志能力,同时兼容持久化日志与前台 UI 日志分发。
/// </summary>
/// <typeparam name="TCategoryName">日志分类类型。</typeparam>
public interface IAppLogger<TCategoryName>
{
/// <summary>
/// 记录一条信息日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogInformation(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条警告日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogWarning(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条带异常的警告日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条错误日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogError(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条带异常的错误日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
void LogError(Exception exception, string message, bool showInUi = false, params object?[] args);
}
/// <summary>
/// 定义 PLC 通信能力。
/// </summary>
public interface IPlcService
{
/// <summary>
/// 读取 PLC 输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>输入信号快照。</returns>
Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken);
/// <summary>
/// 写入 PLC 输出状态与寄存器值。
/// </summary>
/// <param name="state">待写入的输出状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示写入完成的任务。</returns>
Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken);
/// <summary>
/// 主动断开并重建 PLC 连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义扫码枪服务能力。
/// </summary>
public interface IScannerService
{
/// <summary>
/// 触发一次扫码。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken);
/// <summary>
/// 测试扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>连接正常返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
Task<bool> TestConnectionAsync(CancellationToken cancellationToken);
/// <summary>
/// 主动断开并重建扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义 SFTP 校验能力。
/// </summary>
public interface ISftpLookupService
{
/// <summary>
/// 按条码检查目标文件是否存在。
/// </summary>
/// <param name="barcode">条码内容。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件校验结果。</returns>
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
}
/// <summary>
/// 定义安灯接口调用能力。
/// </summary>
public interface IAndonService
{
/// <summary>
/// 发送安灯报警。
/// </summary>
/// <param name="request">报警请求对象。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>报警调用结果。</returns>
Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken);
/// <summary>
/// 发送一次测试报警请求。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>测试调用结果。</returns>
Task<AndonOperationResult> TestAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义流程控制手动操作能力。
/// </summary>
public interface IWorkflowControlService
{
/// <summary>
/// 手动复位流程状态。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示复位完成的任务。</returns>
Task ResetAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动重连 PLC。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ReconnectPlcAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动重连扫码枪。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ReconnectScannerAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动测试安灯接口。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示测试完成的任务。</returns>
Task TestAndonAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义运行态快照与 UI 事件分发能力。
/// </summary>
public interface IAppStateStore
{
/// <summary>
/// 当运行态快照变化时触发。
/// </summary>
event EventHandler<RuntimeSnapshot>? SnapshotChanged;
/// <summary>
/// 当新增日志时触发。
/// </summary>
event EventHandler<UiLogEntry>? LogAdded;
/// <summary>
/// 当新增单板记录时触发。
/// </summary>
event EventHandler<BoardProcessRecord>? RecordAdded;
/// <summary>
/// 获取当前运行态快照副本。
/// </summary>
/// <returns>当前快照副本。</returns>
RuntimeSnapshot GetSnapshot();
/// <summary>
/// 更新当前运行态快照。
/// </summary>
/// <param name="updateAction">用于修改快照的更新委托。</param>
void UpdateSnapshot(Action<RuntimeSnapshot> updateAction);
/// <summary>
/// 追加一条 UI 日志。
/// </summary>
/// <param name="entry">待追加的日志对象。</param>
void AddLog(UiLogEntry entry);
/// <summary>
/// 追加一条单板结果记录。
/// </summary>
/// <param name="record">待追加的记录对象。</param>
void AddRecord(BoardProcessRecord record);
}