✨ feat: 初始化 PCB 检测 WPF 应用程序
* 创建 AxiOmron.PcbCheck 项目主框架及解决方案 * 添加 Dashboard 和系统设置页面 * 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务 * 集成 Andon 报警、工作流托管服务与日志配置 * 补充项目文档和 UI 设计规范
This commit is contained in:
118
src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
Normal file
118
src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
154
src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
Normal file
154
src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
261
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
Normal file
261
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user