feat(*): 添加扫码枪启动探活、全局退出助手及 README

- 添加扫码枪串口启动探活,检测端口占用并更新 UI 状态
- 新增 ShutdownHelper 安全停止 Host 扩展方法
- 新增 README.md 项目说明文档
- 更新 WorkflowHostedService 启动探活逻辑
- 补充 ShutdownHelper 与 WorkflowHostedService 单元测试
- 优化 DashboardPage 与 SystemSettingsPage 界面布局
- 调整 ModbusTcpPlcService 监控镜像读取逻辑
This commit is contained in:
2026-04-19 14:29:07 +08:00
parent 8f74e07c66
commit d70b94e904
26 changed files with 1564 additions and 827 deletions

View File

@@ -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);

View File

@@ -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}");
}

View File

@@ -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 => "系统故障",
_ => "未知结果"
};
}

View File

@@ -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>