diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4508f63..7c93c9a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,9 @@ "Bash(sudo apt-get *)", "Bash(sudo -n true)", "Bash(mkdir -p /tmp/pcb)", - "Read(//tmp/**)" + "Read(//tmp/**)", + "Bash(taskkill /F /IM \"AxiOmron.PcbCheck.exe\")", + "Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")" ] } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..66160c1 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# AxiOmron.PcbCheck + +基于 `.NET 8 + WPF + MVVM Toolkit + Generic Host/DI + NLog` 的 PCB 目检上位机示例工程,包含 PLC 通信、扫码枪触发、SFTP 文件校验、安灯报警和运行态监控。 + +## 项目结构 + +```text +Axi_Omron/ + src/ + AxiOmron.PcbCheck/ + tests/ + AxiOmron.PcbCheck.Tests/ + docs/ + AxiOmron.PcbCheck.slnx +``` + +主程序位于 [src/AxiOmron.PcbCheck](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck),测试位于 [tests/AxiOmron.PcbCheck.Tests](d:/Dev/Codes/MFD_Solution/Axi_Omron/tests/AxiOmron.PcbCheck.Tests)。 + +## 技术栈 + +- `.NET 8` / `WPF` +- `CommunityToolkit.Mvvm` +- `Microsoft.Extensions.Hosting` +- `NLog` +- `IoTClient` Modbus TCP +- `SSH.NET` +- `HandyControl` + +## 配置说明 + +主配置文件位于 [src/AxiOmron.PcbCheck/appConfig.json](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/appConfig.json)。 + +当前 PLC 相关默认配置示例: + +- `Plc.Host`: `127.0.0.1` +- `Plc.Port`: `502` +- `Plc.UnitId`: `1` +- `Plc.PollIntervalMs`: `200` +- `Plc.ReleasePulseMs`: `500` +- `Plc.ReleaseAckTimeoutMs`: `2000` + +点位映射示例: + +- 输入点位 +- `PcbArrived = 0` +- `PlcReset = 1` +- `PlcAckRelease = 2` +- 输出点位 +- `PcBusy = 51` +- `ReleasePermit = 52` +- 寄存器 +- `ResultCode = 0` + +系统设置页可修改 PLC 参数,界面入口在 [SystemSettingsPage.xaml](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml#L58)。 + +注意:配置保存后会写入运行目录下的 `appConfig.json`,当前界面提示为“重启应用后完全生效”。 + +## 构建与运行 + +```powershell +dotnet restore .\src\AxiOmron.PcbCheck\AxiOmron.PcbCheck.csproj +dotnet build .\src\AxiOmron.PcbCheck\AxiOmron.PcbCheck.csproj -c Debug +dotnet run --project .\src\AxiOmron.PcbCheck\AxiOmron.PcbCheck.csproj -c Debug +dotnet test .\tests\AxiOmron.PcbCheck.Tests\AxiOmron.PcbCheck.Tests.csproj +``` + +## PLC 后台轮询调用链 + +### 总览 + +```text +App 启动 + -> BuildHost 注册 WorkflowHostedService 为 HostedService + -> Host.StartAsync() + -> WorkflowHostedService.ExecuteAsync() + +ExecuteAsync 主循环 + -> ProbePlcOnStartupAsync() 启动先探活 + -> while (...) + -> _plcService.ReadSignalsAsync() 读取 PLC 输入 + -> HandleSignalSnapshot() 刷 PLC 连接状态 + -> RefreshPlcMonitorSnapshotAsync() 刷监控区 + -> if PlcReset = true + -> ResetAsync() + else if ShouldStartWorkflow() 命中上升沿 + -> StartWorkflowInBackground() + -> RunWorkflowOnceAsync() + +RunWorkflowOnceAsync 单板流程 + -> InitializeBoardState() + -> ApplyProcessStateAsync(Triggered, PcBusy=true) + -> ExecuteScanFlowAsync() + -> _scannerService.TriggerScanAsync() + -> ExecuteSftpFlowAsync(barcode) + -> _sftpLookupService.CheckFileAsync() + -> ReleaseAndCompleteAsync() + -> ApplyProcessStateAsync(Releasing, ReleasePermit=true) + -> 循环 _plcService.ReadSignalsAsync() 等 PlcAckRelease + -> Delay(ReleasePulseMs) + -> ApplyProcessStateAsync(Completed, ReleasePermit=false, PcBusy=false) + -> _stateStore.AddRecord(...) + +状态/UI 更新支线 + -> PublishRuntimeState() / UpdateSnapshot() + -> AppStateStore.SnapshotChanged + -> MainWindowViewModel.OnSnapshotChanged() + -> DashboardPage / SystemSettingsPage 绑定刷新 +``` + +### 关键代码入口 + +- 后台服务注册: + [App.xaml.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/App.xaml.cs#L125) + [App.xaml.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/App.xaml.cs#L127) +- 轮询主循环: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L70) +- PLC 输入读取: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L81) +- 轮询周期: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L110) +- 启动流程判定: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L366) +- 启动单板流程: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L388) +- 单板总流程: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L496) +- 扫码流程: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L542) +- SFTP 校验流程: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L630) +- 放行及等待 PLC 应答: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L727) +- 流程状态写回 PLC: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L942) + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L954) + +### PLC 底层读写落点 + +- `IPlcService` 接口: + [CoreInterfaces.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs#L97) +- `ReadSignalsAsync` 实现: + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L37) +- 实际读取的输入位: + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L47) + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L48) + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L49) +- 监控区镜像读取: + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L69) +- 输出状态写入: + [ModbusTcpPlcService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs#L113) + +### UI 刷新链 + +- 后台更新运行态快照: + [AppStateStore.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs#L45) +- 主界面监听快照变化: + [MainWindowViewModel.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs#L532) +- 设置页监听快照变化: + [SystemSettingViewModel.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs#L150) + +### 常用排查入口 + +- `PlcReset` 触发软件复位: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L85) +- 轮询异常进入故障锁存: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L103) +- 流程异常进入故障锁存: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L432) +- 故障状态写回: + [WorkflowHostedService.cs](d:/Dev/Codes/MFD_Solution/Axi_Omron/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs#L823) + +## 说明 + +本 README 主要补充程序整体说明与 PLC 后台轮询调用链,便于排查“PLC 轮询在哪里启动、如何触发单板流程、状态如何回写和刷新 UI”这类问题。 diff --git a/docs/2026-04-16-pcb-check-flow-design.md b/docs/2026-04-16-pcb-check-flow-design.md index 1209665..ece1f6e 100644 --- a/docs/2026-04-16-pcb-check-flow-design.md +++ b/docs/2026-04-16-pcb-check-flow-design.md @@ -444,10 +444,9 @@ ### 8.2 点位设计原则 - 输入点和输出点职责分离 -- 过程状态位与最终结果码同时保留 -- 对 PLC 需要快速判断的信号,优先给单独布尔位 -- 对上位机 UI 和日志需要详细表达的结果,使用数值结果码补充 -- 放行信号使用脉冲,忙碌位和完成位可使用保持方式 +- 只保留流程闭环必需的少量点位 +- 详细错误原因由上位机日志与界面表达,不强依赖 PLC 点位细分 +- 放行信号使用脉冲,忙碌位使用保持方式 - 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义 ### 8.3 PLC -> 上位机点位(建议) @@ -456,19 +455,16 @@ | 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | | --- | --- | --- | --- | --- | --- | -| 1 | Discrete Input | 10001 | PlcReady | PLC -> PC | PLC 就绪,允许上位机参与流程 | -| 2 | Discrete Input | 10002 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 | -| 3 | Discrete Input | 10003 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 | -| 4 | Discrete Input | 10004 | PlcAckRelease | PLC -> PC | PLC 已接收到放行信号 | -| 5 | Discrete Input | 10005 | AutoMode | PLC -> PC | 设备当前处于自动模式 | -| 6 | Discrete Input | 10006 | StationEnable | PLC -> PC | 当前工位使能 | +| 1 | Discrete Input | 10001 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 | +| 2 | Discrete Input | 10002 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 | +| 3 | Discrete Input | 10003 | PlcAckRelease | PLC -> PC | PLC 已接收到放行信号,可选 | 说明: - `PcbArrived` 为本流程主触发点 - `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消 - `PlcReset` 用于人工清故障、恢复空闲态或清除保持位 -- `10007 ~ 10050` 预留给后续 PLC -> 上位机扩展信号 +- `10004 ~ 10050` 预留给后续 PLC -> 上位机扩展信号 ### 8.4 上位机 -> PLC 点位(建议) @@ -476,83 +472,42 @@ | 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | | --- | --- | --- | --- | --- | --- | -| 1 | Coil | 00051 | PcOnline | PC -> PLC | 上位机在线心跳位 | -| 2 | Coil | 00052 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB | -| 3 | Coil | 00053 | ScanOk | PC -> PLC | 本次最终扫码成功 | -| 4 | Coil | 00054 | ScanNg | PC -> PLC | 本次最终扫码失败 | -| 5 | Coil | 00055 | FileFound | PC -> PLC | 最终找到对应 SFTP 文件 | -| 6 | Coil | 00056 | FileNotFound | PC -> PLC | 到达重试上限后仍未找到文件 | -| 7 | Coil | 00057 | AlarmRaised | PC -> PLC | 本次流程已触发安灯报警 | -| 8 | Coil | 00058 | ReleasePermit | PC -> PLC | 放行脉冲信号 | -| 9 | Coil | 00059 | ProcessDone | PC -> PLC | 本次流程已结束,结果码稳定 | -| 10 | Coil | 00060 | SystemFault | PC -> PLC | 上位机系统故障 | +| 1 | Coil | 00051 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB | +| 2 | Coil | 00052 | ReleasePermit | PC -> PLC | 放行脉冲信号 | 说明: -- `ScanOk/ScanNg` 互斥 -- `FileFound/FileNotFound` 互斥 - `ReleasePermit` 采用脉冲输出,不建议长时间保持 -- `ProcessDone = 1` 表示寄存器中的结果值已经稳定,PLC 可以在此时读取 `ResultCode` -- `AlarmRaised` 建议保持到下一板开始或收到 `PlcReset` 后再清除 -- `SystemFault` 用于表示上位机自身流程无法继续,例如 PLC 断连、串口异常、配置缺失、SFTP 连接异常等系统级故障 +- `PcBusy = 1` 表示当前板卡流程尚未结束 +- `ResultCode` 在流程进行中和结束后都可读取,不依赖额外完成位 -### 8.5 心跳建议 +### 8.5 结果寄存器设计(建议) -- `PcOnline` 不建议常亮,建议采用**翻转心跳**方式 -- 上位机每 **500ms** 翻转一次 `PcOnline` -- PLC 若在 **3 秒** 内未检测到该位变化,则可判定上位机离线或通信异常 - -### 8.6 结果寄存器设计(建议) - -建议额外使用 Holding Register 表达结果码和统计值。 +建议仅保留一个 Holding Register 表达最终结果。 | 序号 | 地址类型 | 地址 | 名称 | 说明 | | --- | --- | --- | --- | --- | | 1 | Holding Register | 40001 | ResultCode | 本次最终结果代码 | -| 2 | Holding Register | 40002 | ScanTryCount | 本次扫码尝试次数 | -| 3 | Holding Register | 40003 | SftpTryCount | 本次 SFTP 查询次数 | -| 4 | Holding Register | 40004 | AlarmCode | 本次报警代码,未报警时为 0 | -| 5 | Holding Register | 40005 | FlowStateCode | 当前流程状态码 | -### 8.7 推荐结果代码定义 +### 8.6 推荐结果代码定义 | 代码 | 含义 | | --- | --- | | 0 | Idle / 无结果 | | 1 | 处理中 | | 10 | 扫码成功,文件存在,正常放行 | -| 20 | 扫码失败 3 次后放行 | -| 30 | 扫码成功,文件未找到超时放行 | -| 40 | PLC 通信异常 | -| 41 | 串口异常 | -| 42 | SFTP 连接或认证异常 | -| 43 | 安灯接口调用异常 | -| 44 | 配置异常 | +| 20 | 本次处理结果 NG,但按规则放行 | +| 90 | 系统故障 | -### 8.8 推荐流程状态码定义 - -| 代码 | 含义 | -| --- | --- | -| 0 | Idle | -| 1 | Triggered | -| 2 | Scanning | -| 3 | ScanRetrying | -| 4 | CheckingSftp | -| 5 | WaitingSftpRetry | -| 6 | Releasing | -| 7 | Completed | -| 8 | Faulted | - -### 8.9 点位清理策略 +### 8.7 点位清理策略 建议按如下规则清理点位: -- 新板开始前清理:`ScanOk`、`ScanNg`、`FileFound`、`FileNotFound`、`AlarmRaised`、`ProcessDone` +- 新板开始前清理:`PcBusy`、`ReleasePermit`、`ResultCode` - 流程处理中保持:`PcBusy` - 放行时脉冲输出:`ReleasePermit` - 流程结束后清理:`PcBusy` -- `PcOnline` 由心跳任务周期性翻转 -- `SystemFault` 在故障解除且收到人工复位或 `PlcReset` 后清除 +- 故障恢复后在人工复位或 `PlcReset` 时清理 `ResultCode` --- diff --git a/src/AxiOmron.PcbCheck/App.xaml.cs b/src/AxiOmron.PcbCheck/App.xaml.cs index 0359889..5565165 100644 --- a/src/AxiOmron.PcbCheck/App.xaml.cs +++ b/src/AxiOmron.PcbCheck/App.xaml.cs @@ -5,6 +5,7 @@ using System.Windows.Threading; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Interfaces; +using AxiOmron.PcbCheck.Utils; using AxiOmron.PcbCheck.ViewModels; using AxiOmron.PcbCheck.Views.Pages; using AxiOmron.PcbCheck.Views.Windows; @@ -74,7 +75,7 @@ public partial class App : Application { try { - await _host.StopAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + await ShutdownHelper.StopHostAsync(_host, TimeSpan.FromSeconds(2)); } finally { diff --git a/src/AxiOmron.PcbCheck/AssemblyInfo.cs b/src/AxiOmron.PcbCheck/AssemblyInfo.cs index c73865b..e5e48b8 100644 --- a/src/AxiOmron.PcbCheck/AssemblyInfo.cs +++ b/src/AxiOmron.PcbCheck/AssemblyInfo.cs @@ -1,8 +1,10 @@ -using System.Windows; - -[assembly:ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, +using System.Runtime.CompilerServices; +using System.Windows; + +[assembly:InternalsVisibleTo("AxiOmron.PcbCheck.Tests")] +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, // or application resource dictionaries) ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located //(used if a resource is not found in the page, diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs index 4751402..f418e35 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs @@ -67,7 +67,6 @@ public sealed class DesignTimeAppConfigService : IAppConfigService UnitId = 1, PollIntervalMs = 200, ConnectTimeoutMs = 3000, - HeartbeatIntervalMs = 500, ReleasePulseMs = 450, ReleaseAckTimeoutMs = 2500 }, @@ -111,9 +110,6 @@ public sealed class DesignTimeAppConfigService : IAppConfigService }, Workflow = new WorkflowOptions { - RequirePlcReady = true, - RequireAutoMode = true, - RequireStationEnable = true, RequireManualResetAfterFault = true, MaxUiLogEntries = 200, MaxBoardRecords = 100 diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs index 8ee4c70..c19b232 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs @@ -32,19 +32,20 @@ public sealed class DesignTimeAppStateStore : IAppStateStore CurrentBarcode = "PCB240417000128", ResultDescription = "已扫码,等待 SFTP 文件确认", FaultMessage = string.Empty, - ScanTryCount = 1, - SftpTryCount = 2, ResultCode = (ushort)WorkflowResultCode.Processing, - AlarmCode = (ushort)AlarmCode.None, LastTriggeredAt = now.AddSeconds(-18), LastCompletedAt = now.AddMinutes(-2), IsBusy = true, - ProcessDone = false, - SystemFault = false, - AlarmRaised = false, LastUpdatedAt = now }; + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Inputs", Name = "PcbArrived", Address = "10001", CurrentValue = "True", LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Inputs", Name = "PlcReset", Address = "10002", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Inputs", Name = "PlcAckRelease", Address = "10003", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "PcBusy", Address = "51", CurrentValue = "True", LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "ReleasePermit", Address = "52", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Registers", Name = "ResultCode", Address = "40001", CurrentValue = ((ushort)WorkflowResultCode.Processing).ToString(), LastUpdatedAt = now.AddSeconds(-1) }); + _logs = new List { new() { Timestamp = now.AddSeconds(-2), Level = "Info", Message = "SFTP 第 2 次查询:正在检查目标文件。" }, @@ -66,7 +67,6 @@ public sealed class DesignTimeAppStateStore : IAppStateStore ResultCode = (ushort)WorkflowResultCode.Passed, ResultDescription = "OK 放行", ReleaseSent = true, - AlarmRaised = false, ExceptionSummary = string.Empty }, new() @@ -76,10 +76,9 @@ public sealed class DesignTimeAppStateStore : IAppStateStore Barcode = "PCB240417000126", ScanTryCount = 3, SftpTryCount = 0, - ResultCode = (ushort)WorkflowResultCode.ScanFailedReleased, + ResultCode = (ushort)WorkflowResultCode.NgReleased, ResultDescription = "扫码失败后放行", ReleaseSent = true, - AlarmRaised = true, ExceptionSummary = "扫码连续失败三次" }, new() @@ -89,10 +88,9 @@ public sealed class DesignTimeAppStateStore : IAppStateStore Barcode = "PCB240417000127", ScanTryCount = 1, SftpTryCount = 3, - ResultCode = (ushort)WorkflowResultCode.FileNotFoundReleased, - ResultDescription = "文件超时未找到后放行", + ResultCode = (ushort)WorkflowResultCode.NgReleased, + ResultDescription = "处理结果 NG,随后放行", ReleaseSent = true, - AlarmRaised = true, ExceptionSummary = "SFTP 文件查询超时" } }; diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs index ed92cc9..c8b56c4 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs @@ -28,7 +28,7 @@ public sealed class DesignTimeViewModelLocator /// 获取系统设置设计时视图模型。 /// public SystemSettingViewModel SystemSettingViewModel - => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService); + => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService); /// /// 创建首页设计时视图模型。 diff --git a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs index 7c90952..e5599eb 100644 --- a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs +++ b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs @@ -5,6 +5,11 @@ namespace AxiOmron.PcbCheck.Models; /// public sealed class RuntimeSnapshot { + /// + /// 获取 PLC 监控变量集合。 + /// + public IList PlcMonitorItems { get; } = new List(); + /// /// 获取或设置 PLC 连接状态文本。 /// @@ -50,26 +55,11 @@ public sealed class RuntimeSnapshot /// public string FaultMessage { get; set; } = string.Empty; - /// - /// 获取或设置扫码次数。 - /// - public int ScanTryCount { get; set; } - - /// - /// 获取或设置 SFTP 查询次数。 - /// - public int SftpTryCount { get; set; } - /// /// 获取或设置结果代码。 /// public ushort ResultCode { get; set; } - /// - /// 获取或设置报警代码。 - /// - public ushort AlarmCode { get; set; } - /// /// 获取或设置上次触发时间。 /// @@ -85,21 +75,6 @@ public sealed class RuntimeSnapshot /// public bool IsBusy { get; set; } - /// - /// 获取或设置是否已完成。 - /// - public bool ProcessDone { get; set; } - - /// - /// 获取或设置是否存在系统故障。 - /// - public bool SystemFault { get; set; } - - /// - /// 获取或设置是否已触发报警。 - /// - public bool AlarmRaised { get; set; } - /// /// 获取或设置上次状态刷新时间。 /// @@ -111,7 +86,7 @@ public sealed class RuntimeSnapshot /// 新的运行态快照副本。 public RuntimeSnapshot Clone() { - return new RuntimeSnapshot + var clone = new RuntimeSnapshot { PlcStatus = PlcStatus, ScannerStatus = ScannerStatus, @@ -122,17 +97,18 @@ public sealed class RuntimeSnapshot CurrentBarcode = CurrentBarcode, ResultDescription = ResultDescription, FaultMessage = FaultMessage, - ScanTryCount = ScanTryCount, - SftpTryCount = SftpTryCount, ResultCode = ResultCode, - AlarmCode = AlarmCode, LastTriggeredAt = LastTriggeredAt, LastCompletedAt = LastCompletedAt, IsBusy = IsBusy, - ProcessDone = ProcessDone, - SystemFault = SystemFault, - AlarmRaised = AlarmRaised, LastUpdatedAt = LastUpdatedAt }; + + foreach (PlcMonitorItem item in PlcMonitorItems) + { + clone.PlcMonitorItems.Add(item.Clone()); + } + + return clone; } } diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs index e1c6c59..cf53bed 100644 --- a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs +++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs @@ -92,34 +92,14 @@ public enum WorkflowResultCode : ushort ScanFailedReleased = 20, /// - /// 文件未找到超时后放行。 + /// 处理完成但结果为 NG,随后放行。 /// - FileNotFoundReleased = 30, + NgReleased = 20, /// - /// PLC 通信异常。 + /// 系统故障。 /// - PlcCommunicationFault = 40, - - /// - /// 串口异常。 - /// - ScannerFault = 41, - - /// - /// SFTP 连接或认证异常。 - /// - SftpFault = 42, - - /// - /// 安灯接口调用异常。 - /// - AndonFault = 43, - - /// - /// 配置异常。 - /// - ConfigurationFault = 44 + Fault = 90 } /// @@ -163,11 +143,6 @@ public enum AlarmCode : ushort /// public sealed class PlcSignalSnapshot { - /// - /// 获取或设置 PLC 是否就绪。 - /// - public bool PlcReady { get; set; } - /// /// 获取或设置 PCB 是否到位。 /// @@ -183,16 +158,6 @@ public sealed class PlcSignalSnapshot /// public bool PlcAckRelease { get; set; } - /// - /// 获取或设置是否为自动模式。 - /// - public bool AutoMode { get; set; } - - /// - /// 获取或设置工位是否使能。 - /// - public bool StationEnable { get; set; } - /// /// 获取或设置本次快照采集时间。 /// @@ -204,81 +169,21 @@ public sealed class PlcSignalSnapshot /// public sealed class PlcProcessState { - /// - /// 获取或设置 PC 在线位。 - /// - public bool PcOnline { get; set; } - /// /// 获取或设置 PC 忙碌位。 /// public bool PcBusy { get; set; } - /// - /// 获取或设置扫码成功位。 - /// - public bool ScanOk { get; set; } - - /// - /// 获取或设置扫码失败位。 - /// - public bool ScanNg { get; set; } - - /// - /// 获取或设置文件找到位。 - /// - public bool FileFound { get; set; } - - /// - /// 获取或设置文件未找到位。 - /// - public bool FileNotFound { get; set; } - - /// - /// 获取或设置报警位。 - /// - public bool AlarmRaised { get; set; } - /// /// 获取或设置放行位。 /// public bool ReleasePermit { get; set; } - /// - /// 获取或设置流程完成位。 - /// - public bool ProcessDone { get; set; } - - /// - /// 获取或设置系统故障位。 - /// - public bool SystemFault { get; set; } - /// /// 获取或设置结果代码寄存器值。 /// public ushort ResultCode { get; set; } - /// - /// 获取或设置扫码次数寄存器值。 - /// - public ushort ScanTryCount { get; set; } - - /// - /// 获取或设置 SFTP 查询次数寄存器值。 - /// - public ushort SftpTryCount { get; set; } - - /// - /// 获取或设置报警代码寄存器值。 - /// - public ushort AlarmCode { get; set; } - - /// - /// 获取或设置流程状态代码寄存器值。 - /// - public ushort FlowStateCode { get; set; } - /// /// 创建当前状态对象的浅拷贝。 /// @@ -287,25 +192,81 @@ public sealed class PlcProcessState { return new PlcProcessState { - PcOnline = PcOnline, PcBusy = PcBusy, - ScanOk = ScanOk, - ScanNg = ScanNg, - FileFound = FileFound, - FileNotFound = FileNotFound, - AlarmRaised = AlarmRaised, ReleasePermit = ReleasePermit, - ProcessDone = ProcessDone, - SystemFault = SystemFault, - ResultCode = ResultCode, - ScanTryCount = ScanTryCount, - SftpTryCount = SftpTryCount, - AlarmCode = AlarmCode, - FlowStateCode = FlowStateCode + ResultCode = ResultCode }; } } +/// +/// 表示 PLC 监控区域中的单个变量实时镜像。 +/// +public sealed class PlcMonitorItem +{ + /// + /// 获取或设置变量分组。 + /// + public string GroupName { get; set; } = string.Empty; + + /// + /// 获取或设置变量名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置 PLC 变量地址。 + /// + public string Address { get; set; } = string.Empty; + + /// + /// 获取或设置当前值文本。 + /// + public string CurrentValue { get; set; } = string.Empty; + + /// + /// 获取或设置最近一次更新时间。 + /// + public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now; + + /// + /// 创建当前监控项的副本。 + /// + /// 新的监控项对象。 + public PlcMonitorItem Clone() + { + return new PlcMonitorItem + { + GroupName = GroupName, + Name = Name, + Address = Address, + CurrentValue = CurrentValue, + LastUpdatedAt = LastUpdatedAt + }; + } +} + +/// +/// 表示一次从 PLC 读取到的完整监控镜像快照。 +/// +public sealed class PlcMonitorSnapshot +{ + /// + /// 获取或设置输入信号镜像。 + /// + public PlcSignalSnapshot Inputs { get; set; } = new(); + + /// + /// 获取或设置输出与寄存器镜像。 + /// + public PlcProcessState Outputs { get; set; } = new(); + + /// + /// 获取或设置本次镜像采集时间。 + /// + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.Now; +} + /// /// 表示扫码执行结果。 /// @@ -559,35 +520,10 @@ public sealed class BoardProcessRecord } /// -/// 提供流程状态与 PLC 流程代码之间的映射方法。 +/// 提供流程状态到界面文案的映射方法。 /// internal static class WorkflowStateExtensions { - /// - /// 将流程状态映射为 PLC 流程状态码。 - /// - /// 待映射的流程状态。 - /// 对应的流程状态码。 - public static ushort ToFlowStateCode(this WorkflowState state) - { - return state switch - { - WorkflowState.Idle => 0, - WorkflowState.Triggered => 1, - WorkflowState.Scanning => 2, - WorkflowState.ScanRetrying => 3, - WorkflowState.CheckingSftp => 4, - WorkflowState.WaitingSftpRetry => 5, - WorkflowState.Releasing => 6, - WorkflowState.Completed => 7, - WorkflowState.Faulted => 8, - WorkflowState.ScanFailedReleased => 6, - WorkflowState.SftpPassed => 6, - WorkflowState.SftpTimeoutReleased => 6, - _ => 0 - }; - } - /// /// 将流程状态转换为界面显示文本。 /// diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index 56e3106..5635d2d 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -66,11 +66,6 @@ public sealed class PlcOptions /// public int ConnectTimeoutMs { get; set; } = 3000; - /// - /// 获取或设置 PC 在线心跳翻转周期,单位为毫秒。 - /// - public int HeartbeatIntervalMs { get; set; } = 500; - /// /// 获取或设置放行脉冲持续时间,单位为毫秒。 /// @@ -275,21 +270,6 @@ public sealed class AndonOptions /// public sealed class WorkflowOptions { - /// - /// 获取或设置启动流程前是否要求 PLC 就绪。 - /// - public bool RequirePlcReady { get; set; } = true; - - /// - /// 获取或设置启动流程前是否要求自动模式。 - /// - public bool RequireAutoMode { get; set; } = true; - - /// - /// 获取或设置启动流程前是否要求工位使能。 - /// - public bool RequireStationEnable { get; set; } = true; - /// /// 获取或设置故障后是否必须人工复位。 /// @@ -327,35 +307,20 @@ public sealed class SecurityOptions /// public sealed class PlcInputAddressOptions { - /// - /// 获取或设置 PLC 就绪点位地址。 - /// - public int PlcReady { get; set; } = 10001; - /// /// 获取或设置 PCB 到位点位地址。 /// - public int PcbArrived { get; set; } = 10002; + public int PcbArrived { get; set; } = 10001; /// /// 获取或设置 PLC 复位点位地址。 /// - public int PlcReset { get; set; } = 10003; + public int PlcReset { get; set; } = 10002; /// /// 获取或设置 PLC 放行应答点位地址。 /// - public int PlcAckRelease { get; set; } = 10004; - - /// - /// 获取或设置自动模式点位地址。 - /// - public int AutoMode { get; set; } = 10005; - - /// - /// 获取或设置工位使能点位地址。 - /// - public int StationEnable { get; set; } = 10006; + public int PlcAckRelease { get; set; } = 10003; } /// @@ -363,55 +328,15 @@ public sealed class PlcInputAddressOptions /// public sealed class PlcOutputAddressOptions { - /// - /// 获取或设置 PC 在线心跳位地址。 - /// - public int PcOnline { get; set; } = 51; - /// /// 获取或设置 PC 忙碌位地址。 /// - public int PcBusy { get; set; } = 52; - - /// - /// 获取或设置扫码成功位地址。 - /// - public int ScanOk { get; set; } = 53; - - /// - /// 获取或设置扫码失败位地址。 - /// - public int ScanNg { get; set; } = 54; - - /// - /// 获取或设置文件存在位地址。 - /// - public int FileFound { get; set; } = 55; - - /// - /// 获取或设置文件未找到位地址。 - /// - public int FileNotFound { get; set; } = 56; - - /// - /// 获取或设置报警位地址。 - /// - public int AlarmRaised { get; set; } = 57; + public int PcBusy { get; set; } = 51; /// /// 获取或设置放行位地址。 /// - public int ReleasePermit { get; set; } = 58; - - /// - /// 获取或设置流程完成位地址。 - /// - public int ProcessDone { get; set; } = 59; - - /// - /// 获取或设置系统故障位地址。 - /// - public int SystemFault { get; set; } = 60; + public int ReleasePermit { get; set; } = 52; } /// @@ -425,22 +350,4 @@ public sealed class PlcRegisterAddressOptions public int ResultCode { get; set; } = 40001; /// - /// 获取或设置扫码次数寄存器地址。 - /// - public int ScanTryCount { get; set; } = 40002; - - /// - /// 获取或设置 SFTP 查询次数寄存器地址。 - /// - public int SftpTryCount { get; set; } = 40003; - - /// - /// 获取或设置报警代码寄存器地址。 - /// - public int AlarmCode { get; set; } = 40004; - - /// - /// 获取或设置流程状态代码寄存器地址。 - /// - public int FlowStateCode { get; set; } = 40005; } diff --git a/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml b/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..1e728be --- /dev/null +++ b/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,15 @@ + + + + Release + Any CPU + bin\Release\net8.0-windows\publish\ + FileSystem + net8.0-windows + win-x64 + false + true + false + false + + diff --git a/src/AxiOmron.PcbCheck/Properties/launchSettings.json b/src/AxiOmron.PcbCheck/Properties/launchSettings.json new file mode 100644 index 0000000..714055d --- /dev/null +++ b/src/AxiOmron.PcbCheck/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "AxiOmron.PcbCheck": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs index 9a41415..290b3b5 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs @@ -29,9 +29,9 @@ public sealed class AppLogger : IAppLogger /// 记录一条信息日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - 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 : IAppLogger /// 记录一条警告日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - 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 : IAppLogger /// /// 异常对象。 /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 /// 时抛出。 - 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 : IAppLogger /// 记录一条错误日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - 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 : IAppLogger /// /// 异常对象。 /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 /// 时抛出。 - 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); diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs index 2d79565..7c3d1d3 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs @@ -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 } } + /// + /// 读取 PLC 当前监控镜像,包括输入、输出和寄存器的实际值。 + /// + /// 取消令牌。 + /// 监控镜像快照。 + public async Task 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(); + } + } + /// /// 写入 PLC 输出状态与寄存器值。 /// @@ -160,6 +200,32 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable return result.Value; } + /// + /// 读取单个输出线圈位。 + /// + /// 线圈地址。 + /// 读取到的布尔值。 + 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; + } + + /// + /// 读取单个保持寄存器。 + /// + /// 寄存器地址。 + /// 读取到的寄存器值。 + 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; + } + /// /// 写入所有发生变化的线圈位与寄存器。 /// @@ -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); } /// @@ -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}"); } diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index 5aca7ef..eb04a3f 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -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); } + /// + /// 在应用启动后执行一次 PLC 探活,主动建立连接并刷新 PLC 实时镜像。 + /// + /// 取消令牌。若应用正在关闭,则中止本次探活。 + /// 表示探活完成的任务。 + 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}"; + }); + } + } + /// /// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。 /// @@ -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 } } + /// + /// 在应用启动后执行一次扫码枪串口探活,检测端口是否被占用并更新状态。 + /// + /// 取消令牌。若应用正在关闭,则中止本次探活。 + /// 表示探活完成的任务。 + 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 = "异常"); + } + } + /// /// 手动重连扫码枪。 /// @@ -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 = "已连接"; + }); + } + + /// + /// 读取 PLC 实时镜像,并刷新首页监控区展示数据。 + /// + /// 取消令牌。 + /// 表示刷新完成的任务。 + 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; } /// @@ -977,11 +977,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS /// 表示写入完成的任务。 private async Task ApplyProcessStateAsync(WorkflowState state, string description, Action 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 } } - /// - /// 在达到设定周期时翻转 PLC 在线心跳位。 - /// - /// 取消令牌。 - /// 表示更新完成的任务。 - 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); - } - /// /// 发布当前运行态快照到 UI 存储。 /// @@ -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); } + /// + /// 使用最新 PLC 镜像替换快照中的监控集合。 + /// + /// 运行态快照。 + /// PLC 监控镜像。 + 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); + } + + /// + /// 向快照监控集合追加一个监控项。 + /// + /// 运行态快照。 + /// 变量分组。 + /// 变量名称。 + /// PLC 变量地址。 + /// 当前值。 + /// 最近更新时间。 + 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 + }); + } + /// /// 根据结果代码获取中文描述。 /// @@ -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 => "系统故障", _ => "未知结果" }; } diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index 2342d7d..b18e377 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -50,45 +50,45 @@ public interface IAppLogger /// 记录一条信息日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。默认不显示。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - void LogInformation(string message, bool showInUi = false, params object?[] args); + void LogInformation(string message, bool showInUi = true, params object?[] args); /// /// 记录一条警告日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。默认不显示。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - void LogWarning(string message, bool showInUi = false, params object?[] args); + void LogWarning(string message, bool showInUi = true, params object?[] args); /// /// 记录一条带异常的警告日志。 /// /// 异常对象。 /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。默认不显示。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 /// 时抛出。 - void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args); + void LogWarning(Exception exception, string message, bool showInUi = true, params object?[] args); /// /// 记录一条错误日志。 /// /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。默认不显示。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 - void LogError(string message, bool showInUi = false, params object?[] args); + void LogError(string message, bool showInUi = true, params object?[] args); /// /// 记录一条带异常的错误日志。 /// /// 异常对象。 /// 日志消息模板或文本。 - /// 是否同步显示到前台日志区域。默认不显示。 + /// 是否同步显示到前台日志区域。默认 true。 /// 日志模板参数。 /// 时抛出。 - void LogError(Exception exception, string message, bool showInUi = false, params object?[] args); + void LogError(Exception exception, string message, bool showInUi = true, params object?[] args); } /// @@ -103,6 +103,13 @@ public interface IPlcService /// 输入信号快照。 Task ReadSignalsAsync(CancellationToken cancellationToken); + /// + /// 读取 PLC 当前监控镜像,包括输入、输出和寄存器的实际值。 + /// + /// 取消令牌。 + /// 监控镜像快照。 + Task ReadMonitorSnapshotAsync(CancellationToken cancellationToken); + /// /// 写入 PLC 输出状态与寄存器值。 /// diff --git a/src/AxiOmron.PcbCheck/Utils/ShutdownHelper.cs b/src/AxiOmron.PcbCheck/Utils/ShutdownHelper.cs new file mode 100644 index 0000000..5620227 --- /dev/null +++ b/src/AxiOmron.PcbCheck/Utils/ShutdownHelper.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Hosting; + +namespace AxiOmron.PcbCheck.Utils; + +/// +/// 提供应用退出阶段的异步宿主停止辅助能力,确保 await 后续逻辑保留在捕获到的同步上下文中。 +/// +internal static class ShutdownHelper +{ + /// + /// 停止指定 Host,并在停止完成后于捕获的调用上下文中执行收尾回调。 + /// + /// 待停止的宿主实例。 + /// 停止超时时间。 + /// 停止完成后的收尾回调。 + /// 表示停止流程完成的任务。 + /// 为空时抛出。 + internal static async Task StopHostAsync(IHost host, TimeSpan timeout, Action? afterStop = null) + { + ArgumentNullException.ThrowIfNull(host); + + using var cancellationTokenSource = new CancellationTokenSource(timeout); + await host.StopAsync(cancellationTokenSource.Token); + afterStop?.Invoke(); + } +} diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs index f490b65..9aced19 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs @@ -54,6 +54,7 @@ public partial class MainWindowViewModel : ObservableObject Title = "Axi Omron PCB Check"; Logs = new ObservableCollection(); RecentBoards = new ObservableCollection(); + PlcMonitorItems = new ObservableCollection(); AdminUnlockStatus = "管理员功能已锁定"; Logs.CollectionChanged += OnLogsCollectionChanged; @@ -77,6 +78,11 @@ public partial class MainWindowViewModel : ObservableObject /// public ObservableCollection RecentBoards { get; } + /// + /// 获取 PLC 实时监控集合。 + /// + public ObservableCollection PlcMonitorItems { get; } + /// /// 获取最近运行日志集合(与 为同一集合,供 UI 绑定语义更清晰)。 /// @@ -141,30 +147,12 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private string _faultMessage = string.Empty; - /// - /// 获取或设置扫码次数。 - /// - [ObservableProperty] - private int _scanTryCount; - - /// - /// 获取或设置 SFTP 查询次数。 - /// - [ObservableProperty] - private int _sftpTryCount; - /// /// 获取或设置结果代码。 /// [ObservableProperty] private ushort _resultCode; - /// - /// 获取或设置报警代码。 - /// - [ObservableProperty] - private ushort _alarmCode; - /// /// 获取或设置最近触发时间。 /// @@ -183,24 +171,6 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private bool _isBusy; - /// - /// 获取或设置是否存在系统故障。 - /// - [ObservableProperty] - private bool _isFaulted; - - /// - /// 获取或设置是否已完成。 - /// - [ObservableProperty] - private bool _isDone; - - /// - /// 获取或设置是否已触发报警。 - /// - [ObservableProperty] - private bool _isAlarmRaised; - /// /// 获取或设置最近更新时间。 /// @@ -219,12 +189,6 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private int _todayErrorCount; - /// - /// 获取或设置当前活跃告警数量。 - /// - [ObservableProperty] - private int _activeAlarmCount; - /// /// 获取或设置最近一次异常日志的时间文本。 /// @@ -430,27 +394,17 @@ public partial class MainWindowViewModel : ObservableObject CurrentBarcode = snapshot.CurrentBarcode; ResultDescription = snapshot.ResultDescription; FaultMessage = snapshot.FaultMessage; - ScanTryCount = snapshot.ScanTryCount; - SftpTryCount = snapshot.SftpTryCount; ResultCode = snapshot.ResultCode; - AlarmCode = snapshot.AlarmCode; LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; IsBusy = snapshot.IsBusy; - IsFaulted = snapshot.SystemFault; - IsDone = snapshot.ProcessDone; - IsAlarmRaised = snapshot.AlarmRaised; - ActiveAlarmCount = snapshot.AlarmRaised ? 1 : 0; LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"); - } - /// - /// 当 发生变化时同步活跃告警计数。 - /// - /// 最新告警状态。 - partial void OnIsAlarmRaisedChanged(bool value) - { - ActiveAlarmCount = value ? 1 : 0; + PlcMonitorItems.Clear(); + foreach (PlcMonitorItem item in snapshot.PlcMonitorItems) + { + PlcMonitorItems.Add(item.Clone()); + } } /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs index 0a4b63f..3d632de 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Models; +using System.Collections.ObjectModel; namespace AxiOmron.PcbCheck.ViewModels; @@ -13,21 +14,39 @@ public partial class SystemSettingViewModel : ObservableObject { private readonly IAppConfigService _appConfigService; private readonly ISftpLookupService _sftpLookupService; + private readonly IAppStateStore _appStateStore; + private readonly IDispatcherService _dispatcherService; /// /// 初始化系统设置视图模型。 /// /// 配置读写服务。 /// SFTP 连接测试服务。 - public SystemSettingViewModel(IAppConfigService appConfigService, ISftpLookupService sftpLookupService) + /// 运行态快照存储。 + /// UI 线程调度服务。 + public SystemSettingViewModel( + IAppConfigService appConfigService, + ISftpLookupService sftpLookupService, + IAppStateStore appStateStore, + IDispatcherService dispatcherService) { _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService)); + _appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore)); + _dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService)); EditableConfig = _appConfigService.Load(); ConfigPath = _appConfigService.GetConfigPath(); + PlcMonitorItems = new ObservableCollection(); + ApplySnapshot(_appStateStore.GetSnapshot()); + _appStateStore.SnapshotChanged += OnSnapshotChanged; StatusMessage = "已加载配置。"; } + /// + /// 获取 PLC 实时监控集合。 + /// + public ObservableCollection PlcMonitorItems { get; } + /// /// 获取或设置可编辑配置对象。 /// @@ -116,4 +135,27 @@ public partial class SystemSettingViewModel : ObservableObject /// 判断当前是否允许执行 SFTP 测试连接。 /// private bool CanTestSftpConnection => !IsTestingSftpConnection; + + /// + /// 将运行态快照中的 PLC 监控数据同步到当前视图模型。 + /// + /// 运行态快照。 + private void ApplySnapshot(RuntimeSnapshot snapshot) + { + PlcMonitorItems.Clear(); + foreach (PlcMonitorItem item in snapshot.PlcMonitorItems) + { + PlcMonitorItems.Add(item.Clone()); + } + } + + /// + /// 处理运行态快照变更事件,刷新 PLC 监控集合。 + /// + /// 事件源。 + /// 最新快照。 + private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot) + { + await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false); + } } diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml index 60d3334..b1bee4d 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml @@ -407,6 +407,7 @@ + @@ -426,69 +427,69 @@ - - - - - - - - - - - - - - -