✨ feat(*): 添加扫码枪启动探活、全局退出助手及 README
- 添加扫码枪串口启动探活,检测端口占用并更新 UI 状态 - 新增 ShutdownHelper 安全停止 Host 扩展方法 - 新增 README.md 项目说明文档 - 更新 WorkflowHostedService 启动探活逻辑 - 补充 ShutdownHelper 与 WorkflowHostedService 单元测试 - 优化 DashboardPage 与 SystemSettingsPage 界面布局 - 调整 ModbusTcpPlcService 监控镜像读取逻辑
This commit is contained in:
@@ -29,9 +29,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||
/// 记录一条信息日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
public void LogInformation(string message, bool showInUi = false, params object?[] args)
|
||||
public void LogInformation(string message, bool showInUi = true, params object?[] args)
|
||||
{
|
||||
_logger.LogInformation(message, args);
|
||||
PublishUiLog(LogLevel.Information, message, null, showInUi, args);
|
||||
@@ -41,9 +41,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||
/// 记录一条警告日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
public void LogWarning(string message, bool showInUi = false, params object?[] args)
|
||||
public void LogWarning(string message, bool showInUi = true, params object?[] args)
|
||||
{
|
||||
_logger.LogWarning(message, args);
|
||||
PublishUiLog(LogLevel.Warning, message, null, showInUi, args);
|
||||
@@ -54,10 +54,10 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||
/// </summary>
|
||||
/// <param name="exception">异常对象。</param>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</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)
|
||||
public void LogWarning(Exception exception, string message, bool showInUi = true, params object?[] args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
_logger.LogWarning(exception, message, args);
|
||||
@@ -68,9 +68,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||
/// 记录一条错误日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
public void LogError(string message, bool showInUi = false, params object?[] args)
|
||||
public void LogError(string message, bool showInUi = true, params object?[] args)
|
||||
{
|
||||
_logger.LogError(message, args);
|
||||
PublishUiLog(LogLevel.Error, message, null, showInUi, args);
|
||||
@@ -81,10 +81,10 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||
/// </summary>
|
||||
/// <param name="exception">异常对象。</param>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</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)
|
||||
public void LogError(Exception exception, string message, bool showInUi = true, params object?[] args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
_logger.LogError(exception, message, args);
|
||||
|
||||
@@ -44,12 +44,9 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -64,6 +61,49 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 PLC 当前监控镜像,包括输入、输出和寄存器的实际值。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>监控镜像快照。</returns>
|
||||
public async Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureConnected();
|
||||
|
||||
DateTimeOffset capturedAt = DateTimeOffset.Now;
|
||||
return new PlcMonitorSnapshot
|
||||
{
|
||||
Inputs = new PlcSignalSnapshot
|
||||
{
|
||||
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
|
||||
PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
|
||||
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
|
||||
CapturedAt = capturedAt
|
||||
},
|
||||
Outputs = new PlcProcessState
|
||||
{
|
||||
PcBusy = ReadCoil(_options.Outputs.PcBusy),
|
||||
ReleasePermit = ReadCoil(_options.Outputs.ReleasePermit),
|
||||
ResultCode = ReadRegister(_options.Registers.ResultCode)
|
||||
},
|
||||
CapturedAt = capturedAt
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
DisconnectUnsafe();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入 PLC 输出状态与寄存器值。
|
||||
/// </summary>
|
||||
@@ -160,6 +200,32 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取单个输出线圈位。
|
||||
/// </summary>
|
||||
/// <param name="address">线圈地址。</param>
|
||||
/// <returns>读取到的布尔值。</returns>
|
||||
private bool ReadCoil(int address)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.ReadCoil(address, _options.UnitId, 1);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取线圈失败,地址={address}");
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取单个保持寄存器。
|
||||
/// </summary>
|
||||
/// <param name="address">寄存器地址。</param>
|
||||
/// <returns>读取到的寄存器值。</returns>
|
||||
private ushort ReadRegister(int address)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.ReadUInt16(address, _options.UnitId, 3);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取保持寄存器失败,地址={address}");
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入所有发生变化的线圈位与寄存器。
|
||||
/// </summary>
|
||||
@@ -168,22 +234,10 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
||||
{
|
||||
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>
|
||||
@@ -217,8 +271,8 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.Write(address.ToString(), current, _options.UnitId, 6);
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.Write(address.ToString(), current, _options.UnitId);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}");
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
private readonly SemaphoreSlim _plcWriteLock = new(1, 1);
|
||||
private readonly object _stateSyncRoot = new();
|
||||
private PlcProcessState _plcState = new();
|
||||
private bool _heartbeatState;
|
||||
private DateTimeOffset _lastHeartbeatToggleAt = DateTimeOffset.MinValue;
|
||||
private bool _faultLatched;
|
||||
private bool _processingActive;
|
||||
private bool _lastPcbArrived;
|
||||
@@ -33,7 +31,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
private int _scanTryCount;
|
||||
private int _sftpTryCount;
|
||||
private WorkflowResultCode _resultCode = WorkflowResultCode.None;
|
||||
private AlarmCode _alarmCode = AlarmCode.None;
|
||||
private CancellationTokenSource? _activeWorkflowCts;
|
||||
private Task? _activeWorkflowTask;
|
||||
|
||||
@@ -73,16 +70,18 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
||||
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UpdateHeartbeatIfDueAsync(stoppingToken).ConfigureAwait(false);
|
||||
var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false);
|
||||
HandleSignalSnapshot(signals);
|
||||
await RefreshPlcMonitorSnapshotAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
if (signals.PlcReset)
|
||||
{
|
||||
@@ -105,7 +104,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "后台流程轮询异常");
|
||||
await EnterFaultedAsync(WorkflowResultCode.PlcCommunicationFault, AlarmCode.PlcFault, $"后台轮询异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.PlcStatus = $"连接失败: {ex.Message}");
|
||||
await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.PlcFault, $"后台轮询异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Delay(_config.Plc.PollIntervalMs, stoppingToken).ConfigureAwait(false);
|
||||
@@ -114,6 +114,34 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await CancelActiveWorkflowAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在应用启动后执行一次 PLC 探活,主动建立连接并刷新 PLC 实时镜像。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
|
||||
/// <returns>表示探活完成的任务。</returns>
|
||||
public async Task ProbePlcOnStartupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
await RefreshPlcMonitorSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot => snapshot.PlcStatus = "已连接");
|
||||
_appLogger.LogInformation("启动时 PLC 探活成功。", true);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_appLogger.LogInformation("启动时 PLC 探活已取消。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "启动时 PLC 探活异常");
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.PlcStatus = $"连接失败: {ex.Message}";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。
|
||||
/// </summary>
|
||||
@@ -172,11 +200,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||
ResetProcessStateCore();
|
||||
await WritePlcStateAsync(state =>
|
||||
{
|
||||
ResetResultBits(state);
|
||||
state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await WritePlcStateAsync(ResetResultBits, cancellationToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
|
||||
_appLogger.LogInformation("已执行流程复位。", true);
|
||||
}
|
||||
@@ -236,6 +260,41 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在应用启动后执行一次扫码枪串口探活,检测端口是否被占用并更新状态。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
|
||||
/// <returns>表示探活完成的任务。</returns>
|
||||
public async Task ProbeScannerOnStartupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool connected = await _scannerService.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.ScannerStatus = connected ? "在线" : "离线";
|
||||
});
|
||||
|
||||
if (connected)
|
||||
{
|
||||
_appLogger.LogInformation("启动时扫码枪串口探活成功。", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_appLogger.LogWarning("启动时扫码枪串口探活失败: 端口可能被占用或设备未连接。", true);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_appLogger.LogInformation("启动时扫码枪串口探活已取消。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_appLogger.LogError(ex, "启动时扫码枪串口探活异常");
|
||||
UpdateSnapshot(snapshot => snapshot.ScannerStatus = "异常");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重连扫码枪。
|
||||
/// </summary>
|
||||
@@ -317,11 +376,21 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.PlcStatus = signals.PlcReady ? "已连接 / PLC 就绪" : "已连接 / PLC 未就绪";
|
||||
if (!_processingActive && signals.AutoMode && signals.StationEnable)
|
||||
{
|
||||
snapshot.ResultDescription = "满足接板条件";
|
||||
}
|
||||
snapshot.PlcStatus = "已连接";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 PLC 实时镜像,并刷新首页监控区展示数据。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示刷新完成的任务。</returns>
|
||||
private async Task RefreshPlcMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PlcMonitorSnapshot monitorSnapshot = await _plcService.ReadMonitorSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
ReplacePlcMonitorItems(snapshot, monitorSnapshot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,21 +413,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_config.Workflow.RequirePlcReady && !signals.PlcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_config.Workflow.RequireAutoMode && !signals.AutoMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_config.Workflow.RequireStationEnable && !signals.StationEnable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -416,7 +470,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
_appLogger.LogError(ex, "后台流程执行异常");
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await EnterFaultedAsync(WorkflowResultCode.PlcCommunicationFault, AlarmCode.PlcFault, $"后台流程执行异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
|
||||
await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.PlcFault, $"后台流程执行异常: {ex.Message}", CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -540,12 +594,11 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线";
|
||||
snapshot.ScanTryCount = attempt;
|
||||
});
|
||||
|
||||
if (scanResult.IsSystemError)
|
||||
{
|
||||
await EnterFaultedAsync(WorkflowResultCode.ScannerFault, AlarmCode.ScannerFault, scanResult.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.ScannerFault, scanResult.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -554,17 +607,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
_currentBarcode = scanResult.Barcode;
|
||||
_alarmCode = AlarmCode.None;
|
||||
}
|
||||
|
||||
await ApplyProcessStateAsync(
|
||||
WorkflowState.CheckingSftp,
|
||||
$"扫码成功: {scanResult.Barcode}",
|
||||
state =>
|
||||
{
|
||||
state.ScanOk = true;
|
||||
state.ScanNg = false;
|
||||
},
|
||||
_ => { },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true);
|
||||
return scanResult.Barcode;
|
||||
@@ -576,29 +624,20 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
await ApplyProcessStateAsync(
|
||||
WorkflowState.ScanRetrying,
|
||||
$"扫码失败,等待第 {attempt + 1} 次重试",
|
||||
state =>
|
||||
{
|
||||
state.ScanOk = false;
|
||||
state.ScanNg = false;
|
||||
},
|
||||
_ => { },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
_resultCode = WorkflowResultCode.ScanFailedReleased;
|
||||
_alarmCode = _config.Andon.EnableScanFailAlarm ? AlarmCode.ScanFailed : AlarmCode.None;
|
||||
_resultCode = WorkflowResultCode.NgReleased;
|
||||
}
|
||||
|
||||
await ApplyProcessStateAsync(
|
||||
WorkflowState.ScanFailedReleased,
|
||||
"扫码连续失败 3 次,进入报警放行",
|
||||
state =>
|
||||
{
|
||||
state.ScanOk = false;
|
||||
state.ScanNg = true;
|
||||
},
|
||||
_ => { },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await RaiseAlarmIfNeededAsync(
|
||||
@@ -643,7 +682,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证");
|
||||
snapshot.SftpTryCount = _sftpTryCount;
|
||||
});
|
||||
|
||||
if (outcome.Exists)
|
||||
@@ -651,17 +689,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
_resultCode = WorkflowResultCode.Passed;
|
||||
_alarmCode = AlarmCode.None;
|
||||
}
|
||||
|
||||
await ApplyProcessStateAsync(
|
||||
WorkflowState.SftpPassed,
|
||||
$"文件已找到: {outcome.MatchedFilePath}",
|
||||
state =>
|
||||
{
|
||||
state.FileFound = true;
|
||||
state.FileNotFound = false;
|
||||
},
|
||||
_ => { },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true);
|
||||
return;
|
||||
@@ -669,13 +702,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
|
||||
if (outcome.IsConfigurationError)
|
||||
{
|
||||
await EnterFaultedAsync(WorkflowResultCode.ConfigurationFault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome.IsSystemError)
|
||||
{
|
||||
await EnterFaultedAsync(WorkflowResultCode.SftpFault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.SftpFault, outcome.ErrorMessage, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -693,18 +726,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
_resultCode = WorkflowResultCode.FileNotFoundReleased;
|
||||
_alarmCode = _config.Andon.EnableFileNotFoundAlarm ? AlarmCode.FileNotFound : AlarmCode.None;
|
||||
_resultCode = WorkflowResultCode.NgReleased;
|
||||
}
|
||||
|
||||
await ApplyProcessStateAsync(
|
||||
WorkflowState.SftpTimeoutReleased,
|
||||
"文件未找到超时,按规则放行",
|
||||
state =>
|
||||
{
|
||||
state.FileFound = false;
|
||||
state.FileNotFound = true;
|
||||
},
|
||||
_ => { },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true);
|
||||
|
||||
@@ -787,7 +815,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
state =>
|
||||
{
|
||||
state.ReleasePermit = false;
|
||||
state.ProcessDone = true;
|
||||
state.PcBusy = false;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
@@ -795,7 +822,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
var completedAt = DateTimeOffset.Now;
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.ProcessDone = true;
|
||||
snapshot.IsBusy = false;
|
||||
snapshot.LastCompletedAt = completedAt;
|
||||
});
|
||||
@@ -813,7 +839,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
ResultCode = (ushort)_resultCode,
|
||||
ResultDescription = GetResultDescription(_resultCode),
|
||||
ReleaseSent = _releaseSent,
|
||||
AlarmRaised = _plcState.AlarmRaised,
|
||||
AlarmRaised = false,
|
||||
ExceptionSummary = _faultLatched ? "流程故障" : string.Empty
|
||||
};
|
||||
}
|
||||
@@ -836,7 +862,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
_faultLatched = true;
|
||||
_resultCode = resultCode;
|
||||
_alarmCode = alarmCode;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -846,10 +871,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
faultMessage,
|
||||
state =>
|
||||
{
|
||||
state.SystemFault = true;
|
||||
state.PcBusy = false;
|
||||
state.ReleasePermit = false;
|
||||
state.ProcessDone = false;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -861,7 +884,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
_appLogger.LogError($"系统进入故障状态: {faultMessage}", true);
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.SystemFault = true;
|
||||
snapshot.FaultMessage = faultMessage;
|
||||
snapshot.ResultDescription = faultMessage;
|
||||
});
|
||||
@@ -878,22 +900,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
await WritePlcStateAsync(state => state.AlarmRaised = false, cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.AlarmRaised = false;
|
||||
snapshot.AlarmCode = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _andonService.RaiseAlarmAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
await WritePlcStateAsync(state => state.AlarmRaised = result.IsSuccess, cancellationToken).ConfigureAwait(false);
|
||||
UpdateSnapshot(snapshot =>
|
||||
{
|
||||
snapshot.AndonStatus = result.IsSuccess ? "调用成功" : $"调用失败: {result.ErrorMessage}";
|
||||
snapshot.AlarmRaised = result.IsSuccess;
|
||||
snapshot.AlarmCode = request.AlarmCode;
|
||||
});
|
||||
|
||||
if (result.IsSuccess)
|
||||
@@ -919,7 +932,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
_scanTryCount = 0;
|
||||
_sftpTryCount = 0;
|
||||
_resultCode = WorkflowResultCode.Processing;
|
||||
_alarmCode = AlarmCode.None;
|
||||
_faultLatched = false;
|
||||
}
|
||||
}
|
||||
@@ -933,16 +945,14 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
_faultLatched = false;
|
||||
_processingActive = false;
|
||||
_lastPcbArrived = false;
|
||||
// PcbArrived 为 PLC 离散输入,软件复位后仍需等待其先回落再重新产生上升沿。
|
||||
_releaseSent = false;
|
||||
_currentBoardStartedAt = null;
|
||||
_currentBarcode = string.Empty;
|
||||
_scanTryCount = 0;
|
||||
_sftpTryCount = 0;
|
||||
_resultCode = WorkflowResultCode.None;
|
||||
_alarmCode = AlarmCode.None;
|
||||
ResetResultBits(_plcState);
|
||||
_plcState.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,18 +963,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
private static void ResetResultBits(PlcProcessState state)
|
||||
{
|
||||
state.PcBusy = false;
|
||||
state.ScanOk = false;
|
||||
state.ScanNg = false;
|
||||
state.FileFound = false;
|
||||
state.FileNotFound = false;
|
||||
state.AlarmRaised = false;
|
||||
state.ReleasePermit = false;
|
||||
state.ProcessDone = false;
|
||||
state.SystemFault = false;
|
||||
state.ResultCode = 0;
|
||||
state.ScanTryCount = 0;
|
||||
state.SftpTryCount = 0;
|
||||
state.AlarmCode = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -977,11 +977,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
/// <returns>表示写入完成的任务。</returns>
|
||||
private async Task ApplyProcessStateAsync(WorkflowState state, string description, Action<PlcProcessState> extraUpdate, CancellationToken cancellationToken)
|
||||
{
|
||||
await WritePlcStateAsync(plcState =>
|
||||
{
|
||||
plcState.FlowStateCode = state.ToFlowStateCode();
|
||||
extraUpdate(plcState);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await WritePlcStateAsync(extraUpdate, cancellationToken).ConfigureAwait(false);
|
||||
PublishRuntimeState(state, description);
|
||||
}
|
||||
|
||||
@@ -1001,9 +997,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
updateAction(_plcState);
|
||||
_plcState.ResultCode = (ushort)_resultCode;
|
||||
_plcState.ScanTryCount = checked((ushort)_scanTryCount);
|
||||
_plcState.SftpTryCount = checked((ushort)_sftpTryCount);
|
||||
_plcState.AlarmCode = (ushort)_alarmCode;
|
||||
snapshot = _plcState.Clone();
|
||||
}
|
||||
|
||||
@@ -1015,33 +1008,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在达到设定周期时翻转 PLC 在线心跳位。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示更新完成的任务。</returns>
|
||||
private async Task UpdateHeartbeatIfDueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var shouldToggle = false;
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
if (now - _lastHeartbeatToggleAt >= TimeSpan.FromMilliseconds(_config.Plc.HeartbeatIntervalMs))
|
||||
{
|
||||
_heartbeatState = !_heartbeatState;
|
||||
_lastHeartbeatToggleAt = now;
|
||||
shouldToggle = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldToggle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await WritePlcStateAsync(state => state.PcOnline = _heartbeatState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布当前运行态快照到 UI 存储。
|
||||
/// </summary>
|
||||
@@ -1051,29 +1017,17 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
DateTimeOffset? startedAt;
|
||||
string barcode;
|
||||
int scanTryCount;
|
||||
int sftpTryCount;
|
||||
ushort resultCode;
|
||||
ushort alarmCode;
|
||||
bool isBusy;
|
||||
bool processDone;
|
||||
bool systemFault;
|
||||
bool alarmRaised;
|
||||
string faultMessage;
|
||||
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
startedAt = _currentBoardStartedAt;
|
||||
barcode = _currentBarcode;
|
||||
scanTryCount = _scanTryCount;
|
||||
sftpTryCount = _sftpTryCount;
|
||||
resultCode = (ushort)_resultCode;
|
||||
alarmCode = (ushort)_alarmCode;
|
||||
isBusy = _plcState.PcBusy;
|
||||
processDone = _plcState.ProcessDone;
|
||||
systemFault = _plcState.SystemFault;
|
||||
alarmRaised = _plcState.AlarmRaised;
|
||||
faultMessage = systemFault ? description : string.Empty;
|
||||
faultMessage = _faultLatched ? description : string.Empty;
|
||||
}
|
||||
|
||||
UpdateSnapshot(snapshot =>
|
||||
@@ -1083,14 +1037,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
snapshot.CurrentBarcode = barcode;
|
||||
snapshot.ResultDescription = description;
|
||||
snapshot.FaultMessage = faultMessage;
|
||||
snapshot.ScanTryCount = scanTryCount;
|
||||
snapshot.SftpTryCount = sftpTryCount;
|
||||
snapshot.ResultCode = resultCode;
|
||||
snapshot.AlarmCode = alarmCode;
|
||||
snapshot.IsBusy = isBusy;
|
||||
snapshot.ProcessDone = processDone;
|
||||
snapshot.SystemFault = systemFault;
|
||||
snapshot.AlarmRaised = alarmRaised;
|
||||
if (startedAt.HasValue)
|
||||
{
|
||||
snapshot.LastTriggeredAt = startedAt;
|
||||
@@ -1131,6 +1079,44 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
_stateStore.UpdateSnapshot(action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用最新 PLC 镜像替换快照中的监控集合。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">运行态快照。</param>
|
||||
/// <param name="monitorSnapshot">PLC 监控镜像。</param>
|
||||
private void ReplacePlcMonitorItems(RuntimeSnapshot snapshot, PlcMonitorSnapshot monitorSnapshot)
|
||||
{
|
||||
snapshot.PlcMonitorItems.Clear();
|
||||
|
||||
AddMonitorItem(snapshot, "Inputs", "PcbArrived", _config.Plc.Inputs.PcbArrived.ToString(), monitorSnapshot.Inputs.PcbArrived.ToString(), monitorSnapshot.CapturedAt);
|
||||
AddMonitorItem(snapshot, "Inputs", "PlcReset", _config.Plc.Inputs.PlcReset.ToString(), monitorSnapshot.Inputs.PlcReset.ToString(), monitorSnapshot.CapturedAt);
|
||||
AddMonitorItem(snapshot, "Inputs", "PlcAckRelease", _config.Plc.Inputs.PlcAckRelease.ToString(), monitorSnapshot.Inputs.PlcAckRelease.ToString(), monitorSnapshot.CapturedAt);
|
||||
AddMonitorItem(snapshot, "Outputs", "PcBusy", _config.Plc.Outputs.PcBusy.ToString(), monitorSnapshot.Outputs.PcBusy.ToString(), monitorSnapshot.CapturedAt);
|
||||
AddMonitorItem(snapshot, "Outputs", "ReleasePermit", _config.Plc.Outputs.ReleasePermit.ToString(), monitorSnapshot.Outputs.ReleasePermit.ToString(), monitorSnapshot.CapturedAt);
|
||||
AddMonitorItem(snapshot, "Registers", "ResultCode", _config.Plc.Registers.ResultCode.ToString(), monitorSnapshot.Outputs.ResultCode.ToString(), monitorSnapshot.CapturedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向快照监控集合追加一个监控项。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">运行态快照。</param>
|
||||
/// <param name="groupName">变量分组。</param>
|
||||
/// <param name="name">变量名称。</param>
|
||||
/// <param name="address">PLC 变量地址。</param>
|
||||
/// <param name="currentValue">当前值。</param>
|
||||
/// <param name="lastUpdatedAt">最近更新时间。</param>
|
||||
private static void AddMonitorItem(RuntimeSnapshot snapshot, string groupName, string name, string address, string currentValue, DateTimeOffset lastUpdatedAt)
|
||||
{
|
||||
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
|
||||
{
|
||||
GroupName = groupName,
|
||||
Name = name,
|
||||
Address = address,
|
||||
CurrentValue = currentValue,
|
||||
LastUpdatedAt = lastUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据结果代码获取中文描述。
|
||||
/// </summary>
|
||||
@@ -1143,13 +1129,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
WorkflowResultCode.None => "空闲 / 无结果",
|
||||
WorkflowResultCode.Processing => "处理中",
|
||||
WorkflowResultCode.Passed => "扫码成功,文件存在,正常放行",
|
||||
WorkflowResultCode.ScanFailedReleased => "扫码失败 3 次后放行",
|
||||
WorkflowResultCode.FileNotFoundReleased => "扫码成功,文件未找到超时放行",
|
||||
WorkflowResultCode.PlcCommunicationFault => "PLC 通信异常",
|
||||
WorkflowResultCode.ScannerFault => "串口异常",
|
||||
WorkflowResultCode.SftpFault => "SFTP 连接或认证异常",
|
||||
WorkflowResultCode.AndonFault => "安灯接口调用异常",
|
||||
WorkflowResultCode.ConfigurationFault => "配置异常",
|
||||
WorkflowResultCode.NgReleased => "处理完成,结果 NG,随后放行",
|
||||
WorkflowResultCode.Fault => "系统故障",
|
||||
_ => "未知结果"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,45 +50,45 @@ public interface IAppLogger<TCategoryName>
|
||||
/// 记录一条信息日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
void LogInformation(string message, bool showInUi = false, params object?[] args);
|
||||
void LogInformation(string message, bool showInUi = true, params object?[] args);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一条警告日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
void LogWarning(string message, bool showInUi = false, params object?[] args);
|
||||
void LogWarning(string message, bool showInUi = true, params object?[] args);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一条带异常的警告日志。
|
||||
/// </summary>
|
||||
/// <param name="exception">异常对象。</param>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</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);
|
||||
void LogWarning(Exception exception, string message, bool showInUi = true, params object?[] args);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一条错误日志。
|
||||
/// </summary>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
|
||||
/// <param name="args">日志模板参数。</param>
|
||||
void LogError(string message, bool showInUi = false, params object?[] args);
|
||||
void LogError(string message, bool showInUi = true, params object?[] args);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一条带异常的错误日志。
|
||||
/// </summary>
|
||||
/// <param name="exception">异常对象。</param>
|
||||
/// <param name="message">日志消息模板或文本。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
|
||||
/// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</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);
|
||||
void LogError(Exception exception, string message, bool showInUi = true, params object?[] args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +103,13 @@ public interface IPlcService
|
||||
/// <returns>输入信号快照。</returns>
|
||||
Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 读取 PLC 当前监控镜像,包括输入、输出和寄存器的实际值。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>监控镜像快照。</returns>
|
||||
Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 写入 PLC 输出状态与寄存器值。
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user