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

@@ -23,7 +23,9 @@
"Bash(sudo apt-get *)", "Bash(sudo apt-get *)",
"Bash(sudo -n true)", "Bash(sudo -n true)",
"Bash(mkdir -p /tmp/pcb)", "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\")"
] ]
} }
} }

174
README.md Normal file
View File

@@ -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”这类问题。

View File

@@ -444,10 +444,9 @@
### 8.2 点位设计原则 ### 8.2 点位设计原则
- 输入点和输出点职责分离 - 输入点和输出点职责分离
- 过程状态位与最终结果码同时保留 - 只保留流程闭环必需的少量点位
- 对 PLC 需要快速判断的信号,优先给单独布尔位 - 详细错误原因由上位机日志与界面表达,不强依赖 PLC 点位细分
- 对上位机 UI 和日志需要详细表达的结果,使用数值结果码补充 - 放行信号使用脉冲,忙碌位使用保持方式
- 放行信号使用脉冲,忙碌位和完成位可使用保持方式
- 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义 - 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义
### 8.3 PLC -> 上位机点位(建议) ### 8.3 PLC -> 上位机点位(建议)
@@ -456,19 +455,16 @@
| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | | 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 1 | Discrete Input | 10001 | PlcReady | PLC -> PC | PLC 就绪,允许上位机参与流程 | | 1 | Discrete Input | 10001 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 |
| 2 | Discrete Input | 10002 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 | | 2 | Discrete Input | 10002 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 |
| 3 | Discrete Input | 10003 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 | | 3 | Discrete Input | 10003 | PlcAckRelease | 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 | 当前工位使能 |
说明: 说明:
- `PcbArrived` 为本流程主触发点 - `PcbArrived` 为本流程主触发点
- `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消 - `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消
- `PlcReset` 用于人工清故障、恢复空闲态或清除保持位 - `PlcReset` 用于人工清故障、恢复空闲态或清除保持位
- `10007 ~ 10050` 预留给后续 PLC -> 上位机扩展信号 - `10004 ~ 10050` 预留给后续 PLC -> 上位机扩展信号
### 8.4 上位机 -> PLC 点位(建议) ### 8.4 上位机 -> PLC 点位(建议)
@@ -476,83 +472,42 @@
| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | | 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 1 | Coil | 00051 | PcOnline | PC -> PLC | 上位机在线心跳位 | | 1 | Coil | 00051 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB |
| 2 | Coil | 00052 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB | | 2 | Coil | 00052 | ReleasePermit | PC -> PLC | 放行脉冲信号 |
| 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 | 上位机系统故障 |
说明: 说明:
- `ScanOk/ScanNg` 互斥
- `FileFound/FileNotFound` 互斥
- `ReleasePermit` 采用脉冲输出,不建议长时间保持 - `ReleasePermit` 采用脉冲输出,不建议长时间保持
- `ProcessDone = 1` 表示寄存器中的结果值已经稳定PLC 可以在此时读取 `ResultCode` - `PcBusy = 1` 表示当前板卡流程尚未结束
- `AlarmRaised` 建议保持到下一板开始或收到 `PlcReset` 后再清除 - `ResultCode` 在流程进行中和结束后都可读取,不依赖额外完成位
- `SystemFault` 用于表示上位机自身流程无法继续,例如 PLC 断连、串口异常、配置缺失、SFTP 连接异常等系统级故障
### 8.5 心跳建议 ### 8.5 结果寄存器设计(建议
- `PcOnline` 不建议常亮,建议采用**翻转心跳**方式 建议仅保留一个 Holding Register 表达最终结果。
- 上位机每 **500ms** 翻转一次 `PcOnline`
- PLC 若在 **3 秒** 内未检测到该位变化,则可判定上位机离线或通信异常
### 8.6 结果寄存器设计(建议)
建议额外使用 Holding Register 表达结果码和统计值。
| 序号 | 地址类型 | 地址 | 名称 | 说明 | | 序号 | 地址类型 | 地址 | 名称 | 说明 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 1 | Holding Register | 40001 | ResultCode | 本次最终结果代码 | | 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 / 无结果 | | 0 | Idle / 无结果 |
| 1 | 处理中 | | 1 | 处理中 |
| 10 | 扫码成功,文件存在,正常放行 | | 10 | 扫码成功,文件存在,正常放行 |
| 20 | 扫码失败 3 次后放行 | | 20 | 本次处理结果 NG但按规则放行 |
| 30 | 扫码成功,文件未找到超时放行 | | 90 | 系统故障 |
| 40 | PLC 通信异常 |
| 41 | 串口异常 |
| 42 | SFTP 连接或认证异常 |
| 43 | 安灯接口调用异常 |
| 44 | 配置异常 |
### 8.8 推荐流程状态码定义 ### 8.7 点位清理策略
| 代码 | 含义 |
| --- | --- |
| 0 | Idle |
| 1 | Triggered |
| 2 | Scanning |
| 3 | ScanRetrying |
| 4 | CheckingSftp |
| 5 | WaitingSftpRetry |
| 6 | Releasing |
| 7 | Completed |
| 8 | Faulted |
### 8.9 点位清理策略
建议按如下规则清理点位: 建议按如下规则清理点位:
- 新板开始前清理:`ScanOk``ScanNg``FileFound``FileNotFound``AlarmRaised``ProcessDone` - 新板开始前清理:`PcBusy``ReleasePermit``ResultCode`
- 流程处理中保持:`PcBusy` - 流程处理中保持:`PcBusy`
- 放行时脉冲输出:`ReleasePermit` - 放行时脉冲输出:`ReleasePermit`
- 流程结束后清理:`PcBusy` - 流程结束后清理:`PcBusy`
- `PcOnline` 由心跳任务周期性翻转 - 故障恢复后在人工复位或 `PlcReset` 时清理 `ResultCode`
- `SystemFault` 在故障解除且收到人工复位或 `PlcReset` 后清除
--- ---

View File

@@ -5,6 +5,7 @@ using System.Windows.Threading;
using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Implementations;
using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Utils;
using AxiOmron.PcbCheck.ViewModels; using AxiOmron.PcbCheck.ViewModels;
using AxiOmron.PcbCheck.Views.Pages; using AxiOmron.PcbCheck.Views.Pages;
using AxiOmron.PcbCheck.Views.Windows; using AxiOmron.PcbCheck.Views.Windows;
@@ -74,7 +75,7 @@ public partial class App : Application
{ {
try try
{ {
await _host.StopAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); await ShutdownHelper.StopHostAsync(_host, TimeSpan.FromSeconds(2));
} }
finally finally
{ {

View File

@@ -1,5 +1,7 @@
using System.Runtime.CompilerServices;
using System.Windows; using System.Windows;
[assembly:InternalsVisibleTo("AxiOmron.PcbCheck.Tests")]
[assembly:ThemeInfo( [assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page, //(used if a resource is not found in the page,

View File

@@ -67,7 +67,6 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
UnitId = 1, UnitId = 1,
PollIntervalMs = 200, PollIntervalMs = 200,
ConnectTimeoutMs = 3000, ConnectTimeoutMs = 3000,
HeartbeatIntervalMs = 500,
ReleasePulseMs = 450, ReleasePulseMs = 450,
ReleaseAckTimeoutMs = 2500 ReleaseAckTimeoutMs = 2500
}, },
@@ -111,9 +110,6 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
}, },
Workflow = new WorkflowOptions Workflow = new WorkflowOptions
{ {
RequirePlcReady = true,
RequireAutoMode = true,
RequireStationEnable = true,
RequireManualResetAfterFault = true, RequireManualResetAfterFault = true,
MaxUiLogEntries = 200, MaxUiLogEntries = 200,
MaxBoardRecords = 100 MaxBoardRecords = 100

View File

@@ -32,19 +32,20 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
CurrentBarcode = "PCB240417000128", CurrentBarcode = "PCB240417000128",
ResultDescription = "已扫码,等待 SFTP 文件确认", ResultDescription = "已扫码,等待 SFTP 文件确认",
FaultMessage = string.Empty, FaultMessage = string.Empty,
ScanTryCount = 1,
SftpTryCount = 2,
ResultCode = (ushort)WorkflowResultCode.Processing, ResultCode = (ushort)WorkflowResultCode.Processing,
AlarmCode = (ushort)AlarmCode.None,
LastTriggeredAt = now.AddSeconds(-18), LastTriggeredAt = now.AddSeconds(-18),
LastCompletedAt = now.AddMinutes(-2), LastCompletedAt = now.AddMinutes(-2),
IsBusy = true, IsBusy = true,
ProcessDone = false,
SystemFault = false,
AlarmRaised = false,
LastUpdatedAt = now 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<UiLogEntry> _logs = new List<UiLogEntry>
{ {
new() { Timestamp = now.AddSeconds(-2), Level = "Info", Message = "SFTP 第 2 次查询:正在检查目标文件。" }, new() { Timestamp = now.AddSeconds(-2), Level = "Info", Message = "SFTP 第 2 次查询:正在检查目标文件。" },
@@ -66,7 +67,6 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
ResultCode = (ushort)WorkflowResultCode.Passed, ResultCode = (ushort)WorkflowResultCode.Passed,
ResultDescription = "OK 放行", ResultDescription = "OK 放行",
ReleaseSent = true, ReleaseSent = true,
AlarmRaised = false,
ExceptionSummary = string.Empty ExceptionSummary = string.Empty
}, },
new() new()
@@ -76,10 +76,9 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
Barcode = "PCB240417000126", Barcode = "PCB240417000126",
ScanTryCount = 3, ScanTryCount = 3,
SftpTryCount = 0, SftpTryCount = 0,
ResultCode = (ushort)WorkflowResultCode.ScanFailedReleased, ResultCode = (ushort)WorkflowResultCode.NgReleased,
ResultDescription = "扫码失败后放行", ResultDescription = "扫码失败后放行",
ReleaseSent = true, ReleaseSent = true,
AlarmRaised = true,
ExceptionSummary = "扫码连续失败三次" ExceptionSummary = "扫码连续失败三次"
}, },
new() new()
@@ -89,10 +88,9 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
Barcode = "PCB240417000127", Barcode = "PCB240417000127",
ScanTryCount = 1, ScanTryCount = 1,
SftpTryCount = 3, SftpTryCount = 3,
ResultCode = (ushort)WorkflowResultCode.FileNotFoundReleased, ResultCode = (ushort)WorkflowResultCode.NgReleased,
ResultDescription = "文件超时未找到后放行", ResultDescription = "处理结果 NG后放行",
ReleaseSent = true, ReleaseSent = true,
AlarmRaised = true,
ExceptionSummary = "SFTP 文件查询超时" ExceptionSummary = "SFTP 文件查询超时"
} }
}; };

View File

@@ -28,7 +28,7 @@ public sealed class DesignTimeViewModelLocator
/// 获取系统设置设计时视图模型。 /// 获取系统设置设计时视图模型。
/// </summary> /// </summary>
public SystemSettingViewModel SystemSettingViewModel public SystemSettingViewModel SystemSettingViewModel
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService); => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService);
/// <summary> /// <summary>
/// 创建首页设计时视图模型。 /// 创建首页设计时视图模型。

View File

@@ -5,6 +5,11 @@ namespace AxiOmron.PcbCheck.Models;
/// </summary> /// </summary>
public sealed class RuntimeSnapshot public sealed class RuntimeSnapshot
{ {
/// <summary>
/// 获取 PLC 监控变量集合。
/// </summary>
public IList<PlcMonitorItem> PlcMonitorItems { get; } = new List<PlcMonitorItem>();
/// <summary> /// <summary>
/// 获取或设置 PLC 连接状态文本。 /// 获取或设置 PLC 连接状态文本。
/// </summary> /// </summary>
@@ -50,26 +55,11 @@ public sealed class RuntimeSnapshot
/// </summary> /// </summary>
public string FaultMessage { get; set; } = string.Empty; public string FaultMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置扫码次数。
/// </summary>
public int ScanTryCount { get; set; }
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
public int SftpTryCount { get; set; }
/// <summary> /// <summary>
/// 获取或设置结果代码。 /// 获取或设置结果代码。
/// </summary> /// </summary>
public ushort ResultCode { get; set; } public ushort ResultCode { get; set; }
/// <summary>
/// 获取或设置报警代码。
/// </summary>
public ushort AlarmCode { get; set; }
/// <summary> /// <summary>
/// 获取或设置上次触发时间。 /// 获取或设置上次触发时间。
/// </summary> /// </summary>
@@ -85,21 +75,6 @@ public sealed class RuntimeSnapshot
/// </summary> /// </summary>
public bool IsBusy { get; set; } public bool IsBusy { get; set; }
/// <summary>
/// 获取或设置是否已完成。
/// </summary>
public bool ProcessDone { get; set; }
/// <summary>
/// 获取或设置是否存在系统故障。
/// </summary>
public bool SystemFault { get; set; }
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
public bool AlarmRaised { get; set; }
/// <summary> /// <summary>
/// 获取或设置上次状态刷新时间。 /// 获取或设置上次状态刷新时间。
/// </summary> /// </summary>
@@ -111,7 +86,7 @@ public sealed class RuntimeSnapshot
/// <returns>新的运行态快照副本。</returns> /// <returns>新的运行态快照副本。</returns>
public RuntimeSnapshot Clone() public RuntimeSnapshot Clone()
{ {
return new RuntimeSnapshot var clone = new RuntimeSnapshot
{ {
PlcStatus = PlcStatus, PlcStatus = PlcStatus,
ScannerStatus = ScannerStatus, ScannerStatus = ScannerStatus,
@@ -122,17 +97,18 @@ public sealed class RuntimeSnapshot
CurrentBarcode = CurrentBarcode, CurrentBarcode = CurrentBarcode,
ResultDescription = ResultDescription, ResultDescription = ResultDescription,
FaultMessage = FaultMessage, FaultMessage = FaultMessage,
ScanTryCount = ScanTryCount,
SftpTryCount = SftpTryCount,
ResultCode = ResultCode, ResultCode = ResultCode,
AlarmCode = AlarmCode,
LastTriggeredAt = LastTriggeredAt, LastTriggeredAt = LastTriggeredAt,
LastCompletedAt = LastCompletedAt, LastCompletedAt = LastCompletedAt,
IsBusy = IsBusy, IsBusy = IsBusy,
ProcessDone = ProcessDone,
SystemFault = SystemFault,
AlarmRaised = AlarmRaised,
LastUpdatedAt = LastUpdatedAt LastUpdatedAt = LastUpdatedAt
}; };
foreach (PlcMonitorItem item in PlcMonitorItems)
{
clone.PlcMonitorItems.Add(item.Clone());
}
return clone;
} }
} }

View File

@@ -92,34 +92,14 @@ public enum WorkflowResultCode : ushort
ScanFailedReleased = 20, ScanFailedReleased = 20,
/// <summary> /// <summary>
/// 文件未找到超时后放行。 /// 处理完成但结果为 NG后放行。
/// </summary> /// </summary>
FileNotFoundReleased = 30, NgReleased = 20,
/// <summary> /// <summary>
/// PLC 通信异常 /// 系统故障
/// </summary> /// </summary>
PlcCommunicationFault = 40, Fault = 90
/// <summary>
/// 串口异常。
/// </summary>
ScannerFault = 41,
/// <summary>
/// SFTP 连接或认证异常。
/// </summary>
SftpFault = 42,
/// <summary>
/// 安灯接口调用异常。
/// </summary>
AndonFault = 43,
/// <summary>
/// 配置异常。
/// </summary>
ConfigurationFault = 44
} }
/// <summary> /// <summary>
@@ -163,11 +143,6 @@ public enum AlarmCode : ushort
/// </summary> /// </summary>
public sealed class PlcSignalSnapshot public sealed class PlcSignalSnapshot
{ {
/// <summary>
/// 获取或设置 PLC 是否就绪。
/// </summary>
public bool PlcReady { get; set; }
/// <summary> /// <summary>
/// 获取或设置 PCB 是否到位。 /// 获取或设置 PCB 是否到位。
/// </summary> /// </summary>
@@ -183,16 +158,6 @@ public sealed class PlcSignalSnapshot
/// </summary> /// </summary>
public bool PlcAckRelease { get; set; } public bool PlcAckRelease { get; set; }
/// <summary>
/// 获取或设置是否为自动模式。
/// </summary>
public bool AutoMode { get; set; }
/// <summary>
/// 获取或设置工位是否使能。
/// </summary>
public bool StationEnable { get; set; }
/// <summary> /// <summary>
/// 获取或设置本次快照采集时间。 /// 获取或设置本次快照采集时间。
/// </summary> /// </summary>
@@ -204,81 +169,21 @@ public sealed class PlcSignalSnapshot
/// </summary> /// </summary>
public sealed class PlcProcessState public sealed class PlcProcessState
{ {
/// <summary>
/// 获取或设置 PC 在线位。
/// </summary>
public bool PcOnline { get; set; }
/// <summary> /// <summary>
/// 获取或设置 PC 忙碌位。 /// 获取或设置 PC 忙碌位。
/// </summary> /// </summary>
public bool PcBusy { get; set; } public bool PcBusy { get; set; }
/// <summary>
/// 获取或设置扫码成功位。
/// </summary>
public bool ScanOk { get; set; }
/// <summary>
/// 获取或设置扫码失败位。
/// </summary>
public bool ScanNg { get; set; }
/// <summary>
/// 获取或设置文件找到位。
/// </summary>
public bool FileFound { get; set; }
/// <summary>
/// 获取或设置文件未找到位。
/// </summary>
public bool FileNotFound { get; set; }
/// <summary>
/// 获取或设置报警位。
/// </summary>
public bool AlarmRaised { get; set; }
/// <summary> /// <summary>
/// 获取或设置放行位。 /// 获取或设置放行位。
/// </summary> /// </summary>
public bool ReleasePermit { get; set; } public bool ReleasePermit { get; set; }
/// <summary>
/// 获取或设置流程完成位。
/// </summary>
public bool ProcessDone { get; set; }
/// <summary>
/// 获取或设置系统故障位。
/// </summary>
public bool SystemFault { get; set; }
/// <summary> /// <summary>
/// 获取或设置结果代码寄存器值。 /// 获取或设置结果代码寄存器值。
/// </summary> /// </summary>
public ushort ResultCode { get; set; } public ushort ResultCode { get; set; }
/// <summary>
/// 获取或设置扫码次数寄存器值。
/// </summary>
public ushort ScanTryCount { get; set; }
/// <summary>
/// 获取或设置 SFTP 查询次数寄存器值。
/// </summary>
public ushort SftpTryCount { get; set; }
/// <summary>
/// 获取或设置报警代码寄存器值。
/// </summary>
public ushort AlarmCode { get; set; }
/// <summary>
/// 获取或设置流程状态代码寄存器值。
/// </summary>
public ushort FlowStateCode { get; set; }
/// <summary> /// <summary>
/// 创建当前状态对象的浅拷贝。 /// 创建当前状态对象的浅拷贝。
/// </summary> /// </summary>
@@ -287,25 +192,81 @@ public sealed class PlcProcessState
{ {
return new PlcProcessState return new PlcProcessState
{ {
PcOnline = PcOnline,
PcBusy = PcBusy, PcBusy = PcBusy,
ScanOk = ScanOk,
ScanNg = ScanNg,
FileFound = FileFound,
FileNotFound = FileNotFound,
AlarmRaised = AlarmRaised,
ReleasePermit = ReleasePermit, ReleasePermit = ReleasePermit,
ProcessDone = ProcessDone, ResultCode = ResultCode
SystemFault = SystemFault,
ResultCode = ResultCode,
ScanTryCount = ScanTryCount,
SftpTryCount = SftpTryCount,
AlarmCode = AlarmCode,
FlowStateCode = FlowStateCode
}; };
} }
} }
/// <summary>
/// 表示 PLC 监控区域中的单个变量实时镜像。
/// </summary>
public sealed class PlcMonitorItem
{
/// <summary>
/// 获取或设置变量分组。
/// </summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>
/// 获取或设置变量名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置 PLC 变量地址。
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// 获取或设置当前值文本。
/// </summary>
public string CurrentValue { get; set; } = string.Empty;
/// <summary>
/// 获取或设置最近一次更新时间。
/// </summary>
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
/// <summary>
/// 创建当前监控项的副本。
/// </summary>
/// <returns>新的监控项对象。</returns>
public PlcMonitorItem Clone()
{
return new PlcMonitorItem
{
GroupName = GroupName,
Name = Name,
Address = Address,
CurrentValue = CurrentValue,
LastUpdatedAt = LastUpdatedAt
};
}
}
/// <summary>
/// 表示一次从 PLC 读取到的完整监控镜像快照。
/// </summary>
public sealed class PlcMonitorSnapshot
{
/// <summary>
/// 获取或设置输入信号镜像。
/// </summary>
public PlcSignalSnapshot Inputs { get; set; } = new();
/// <summary>
/// 获取或设置输出与寄存器镜像。
/// </summary>
public PlcProcessState Outputs { get; set; } = new();
/// <summary>
/// 获取或设置本次镜像采集时间。
/// </summary>
public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.Now;
}
/// <summary> /// <summary>
/// 表示扫码执行结果。 /// 表示扫码执行结果。
/// </summary> /// </summary>
@@ -559,35 +520,10 @@ public sealed class BoardProcessRecord
} }
/// <summary> /// <summary>
/// 提供流程状态与 PLC 流程代码之间的映射方法。 /// 提供流程状态到界面文案的映射方法。
/// </summary> /// </summary>
internal static class WorkflowStateExtensions internal static class WorkflowStateExtensions
{ {
/// <summary>
/// 将流程状态映射为 PLC 流程状态码。
/// </summary>
/// <param name="state">待映射的流程状态。</param>
/// <returns>对应的流程状态码。</returns>
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
};
}
/// <summary> /// <summary>
/// 将流程状态转换为界面显示文本。 /// 将流程状态转换为界面显示文本。
/// </summary> /// </summary>

View File

@@ -66,11 +66,6 @@ public sealed class PlcOptions
/// </summary> /// </summary>
public int ConnectTimeoutMs { get; set; } = 3000; public int ConnectTimeoutMs { get; set; } = 3000;
/// <summary>
/// 获取或设置 PC 在线心跳翻转周期,单位为毫秒。
/// </summary>
public int HeartbeatIntervalMs { get; set; } = 500;
/// <summary> /// <summary>
/// 获取或设置放行脉冲持续时间,单位为毫秒。 /// 获取或设置放行脉冲持续时间,单位为毫秒。
/// </summary> /// </summary>
@@ -275,21 +270,6 @@ public sealed class AndonOptions
/// </summary> /// </summary>
public sealed class WorkflowOptions public sealed class WorkflowOptions
{ {
/// <summary>
/// 获取或设置启动流程前是否要求 PLC 就绪。
/// </summary>
public bool RequirePlcReady { get; set; } = true;
/// <summary>
/// 获取或设置启动流程前是否要求自动模式。
/// </summary>
public bool RequireAutoMode { get; set; } = true;
/// <summary>
/// 获取或设置启动流程前是否要求工位使能。
/// </summary>
public bool RequireStationEnable { get; set; } = true;
/// <summary> /// <summary>
/// 获取或设置故障后是否必须人工复位。 /// 获取或设置故障后是否必须人工复位。
/// </summary> /// </summary>
@@ -327,35 +307,20 @@ public sealed class SecurityOptions
/// </summary> /// </summary>
public sealed class PlcInputAddressOptions public sealed class PlcInputAddressOptions
{ {
/// <summary>
/// 获取或设置 PLC 就绪点位地址。
/// </summary>
public int PlcReady { get; set; } = 10001;
/// <summary> /// <summary>
/// 获取或设置 PCB 到位点位地址。 /// 获取或设置 PCB 到位点位地址。
/// </summary> /// </summary>
public int PcbArrived { get; set; } = 10002; public int PcbArrived { get; set; } = 10001;
/// <summary> /// <summary>
/// 获取或设置 PLC 复位点位地址。 /// 获取或设置 PLC 复位点位地址。
/// </summary> /// </summary>
public int PlcReset { get; set; } = 10003; public int PlcReset { get; set; } = 10002;
/// <summary> /// <summary>
/// 获取或设置 PLC 放行应答点位地址。 /// 获取或设置 PLC 放行应答点位地址。
/// </summary> /// </summary>
public int PlcAckRelease { get; set; } = 10004; public int PlcAckRelease { get; set; } = 10003;
/// <summary>
/// 获取或设置自动模式点位地址。
/// </summary>
public int AutoMode { get; set; } = 10005;
/// <summary>
/// 获取或设置工位使能点位地址。
/// </summary>
public int StationEnable { get; set; } = 10006;
} }
/// <summary> /// <summary>
@@ -363,55 +328,15 @@ public sealed class PlcInputAddressOptions
/// </summary> /// </summary>
public sealed class PlcOutputAddressOptions public sealed class PlcOutputAddressOptions
{ {
/// <summary>
/// 获取或设置 PC 在线心跳位地址。
/// </summary>
public int PcOnline { get; set; } = 51;
/// <summary> /// <summary>
/// 获取或设置 PC 忙碌位地址。 /// 获取或设置 PC 忙碌位地址。
/// </summary> /// </summary>
public int PcBusy { get; set; } = 52; public int PcBusy { get; set; } = 51;
/// <summary>
/// 获取或设置扫码成功位地址。
/// </summary>
public int ScanOk { get; set; } = 53;
/// <summary>
/// 获取或设置扫码失败位地址。
/// </summary>
public int ScanNg { get; set; } = 54;
/// <summary>
/// 获取或设置文件存在位地址。
/// </summary>
public int FileFound { get; set; } = 55;
/// <summary>
/// 获取或设置文件未找到位地址。
/// </summary>
public int FileNotFound { get; set; } = 56;
/// <summary>
/// 获取或设置报警位地址。
/// </summary>
public int AlarmRaised { get; set; } = 57;
/// <summary> /// <summary>
/// 获取或设置放行位地址。 /// 获取或设置放行位地址。
/// </summary> /// </summary>
public int ReleasePermit { get; set; } = 58; public int ReleasePermit { get; set; } = 52;
/// <summary>
/// 获取或设置流程完成位地址。
/// </summary>
public int ProcessDone { get; set; } = 59;
/// <summary>
/// 获取或设置系统故障位地址。
/// </summary>
public int SystemFault { get; set; } = 60;
} }
/// <summary> /// <summary>
@@ -425,22 +350,4 @@ public sealed class PlcRegisterAddressOptions
public int ResultCode { get; set; } = 40001; public int ResultCode { get; set; } = 40001;
/// <summary> /// <summary>
/// 获取或设置扫码次数寄存器地址。
/// </summary>
public int ScanTryCount { get; set; } = 40002;
/// <summary>
/// 获取或设置 SFTP 查询次数寄存器地址。
/// </summary>
public int SftpTryCount { get; set; } = 40003;
/// <summary>
/// 获取或设置报警代码寄存器地址。
/// </summary>
public int AlarmCode { get; set; } = 40004;
/// <summary>
/// 获取或设置流程状态代码寄存器地址。
/// </summary>
public int FlowStateCode { get; set; } = 40005;
} }

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"AxiOmron.PcbCheck": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -29,9 +29,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
/// 记录一条信息日志。 /// 记录一条信息日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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); _logger.LogInformation(message, args);
PublishUiLog(LogLevel.Information, message, null, showInUi, args); PublishUiLog(LogLevel.Information, message, null, showInUi, args);
@@ -41,9 +41,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
/// 记录一条警告日志。 /// 记录一条警告日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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); _logger.LogWarning(message, args);
PublishUiLog(LogLevel.Warning, message, null, showInUi, args); PublishUiLog(LogLevel.Warning, message, null, showInUi, args);
@@ -54,10 +54,10 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
/// </summary> /// </summary>
/// <param name="exception">异常对象。</param> /// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</param> /// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception> /// <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); ArgumentNullException.ThrowIfNull(exception);
_logger.LogWarning(exception, message, args); _logger.LogWarning(exception, message, args);
@@ -68,9 +68,9 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
/// 记录一条错误日志。 /// 记录一条错误日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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); _logger.LogError(message, args);
PublishUiLog(LogLevel.Error, message, null, showInUi, args); PublishUiLog(LogLevel.Error, message, null, showInUi, args);
@@ -81,10 +81,10 @@ public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
/// </summary> /// </summary>
/// <param name="exception">异常对象。</param> /// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</param> /// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception> /// <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); ArgumentNullException.ThrowIfNull(exception);
_logger.LogError(exception, message, args); _logger.LogError(exception, message, args);

View File

@@ -44,12 +44,9 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
return new PlcSignalSnapshot return new PlcSignalSnapshot
{ {
PlcReady = ReadDiscrete(_options.Inputs.PlcReady),
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived), PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
PlcReset = ReadDiscrete(_options.Inputs.PlcReset), PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease), PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
AutoMode = ReadDiscrete(_options.Inputs.AutoMode),
StationEnable = ReadDiscrete(_options.Inputs.StationEnable),
CapturedAt = DateTimeOffset.Now 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> /// <summary>
/// 写入 PLC 输出状态与寄存器值。 /// 写入 PLC 输出状态与寄存器值。
/// </summary> /// </summary>
@@ -160,6 +200,32 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
return result.Value; 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>
/// 写入所有发生变化的线圈位与寄存器。 /// 写入所有发生变化的线圈位与寄存器。
/// </summary> /// </summary>
@@ -168,22 +234,10 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
{ {
var previous = _lastWrittenState; var previous = _lastWrittenState;
WriteSingleCoilIfChanged(previous?.PcOnline, state.PcOnline, _options.Outputs.PcOnline);
WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy); 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?.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?.ResultCode, state.ResultCode, _options.Registers.ResultCode);
WriteSingleRegisterIfChanged(previous?.ScanTryCount, state.ScanTryCount, _options.Registers.ScanTryCount);
WriteSingleRegisterIfChanged(previous?.SftpTryCount, state.SftpTryCount, _options.Registers.SftpTryCount);
WriteSingleRegisterIfChanged(previous?.AlarmCode, state.AlarmCode, _options.Registers.AlarmCode);
WriteSingleRegisterIfChanged(previous?.FlowStateCode, state.FlowStateCode, _options.Registers.FlowStateCode);
} }
/// <summary> /// <summary>
@@ -218,7 +272,7 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
} }
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.Write(address.ToString(), current, _options.UnitId, 6); var result = client.Write(address.ToString(), current, _options.UnitId);
EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}"); 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 SemaphoreSlim _plcWriteLock = new(1, 1);
private readonly object _stateSyncRoot = new(); private readonly object _stateSyncRoot = new();
private PlcProcessState _plcState = new(); private PlcProcessState _plcState = new();
private bool _heartbeatState;
private DateTimeOffset _lastHeartbeatToggleAt = DateTimeOffset.MinValue;
private bool _faultLatched; private bool _faultLatched;
private bool _processingActive; private bool _processingActive;
private bool _lastPcbArrived; private bool _lastPcbArrived;
@@ -33,7 +31,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
private int _scanTryCount; private int _scanTryCount;
private int _sftpTryCount; private int _sftpTryCount;
private WorkflowResultCode _resultCode = WorkflowResultCode.None; private WorkflowResultCode _resultCode = WorkflowResultCode.None;
private AlarmCode _alarmCode = AlarmCode.None;
private CancellationTokenSource? _activeWorkflowCts; private CancellationTokenSource? _activeWorkflowCts;
private Task? _activeWorkflowTask; private Task? _activeWorkflowTask;
@@ -73,16 +70,18 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true); _appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false); await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位"); PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
await UpdateHeartbeatIfDueAsync(stoppingToken).ConfigureAwait(false);
var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false); var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false);
HandleSignalSnapshot(signals); HandleSignalSnapshot(signals);
await RefreshPlcMonitorSnapshotAsync(stoppingToken).ConfigureAwait(false);
if (signals.PlcReset) if (signals.PlcReset)
{ {
@@ -105,7 +104,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
catch (Exception ex) catch (Exception ex)
{ {
_appLogger.LogError(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); 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); 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> /// <summary>
/// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。 /// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。
/// </summary> /// </summary>
@@ -172,11 +200,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false); await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
ResetProcessStateCore(); ResetProcessStateCore();
await WritePlcStateAsync(state => await WritePlcStateAsync(ResetResultBits, cancellationToken).ConfigureAwait(false);
{
ResetResultBits(state);
state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
}, cancellationToken).ConfigureAwait(false);
PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位"); PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
_appLogger.LogInformation("已执行流程复位。", true); _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>
/// 手动重连扫码枪。 /// 手动重连扫码枪。
/// </summary> /// </summary>
@@ -317,11 +376,21 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.PlcStatus = signals.PlcReady ? "已连接 / PLC 就绪" : "已连接 / PLC 未就绪"; snapshot.PlcStatus = "已连接";
if (!_processingActive && signals.AutoMode && signals.StationEnable) });
{ }
snapshot.ResultDescription = "满足接板条件";
} /// <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; 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; return true;
} }
} }
@@ -416,7 +470,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_appLogger.LogError(ex, "后台流程执行异常"); _appLogger.LogError(ex, "后台流程执行异常");
if (!stoppingToken.IsCancellationRequested) 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 finally
@@ -540,12 +594,11 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线"; snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线";
snapshot.ScanTryCount = attempt;
}); });
if (scanResult.IsSystemError) 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; return string.Empty;
} }
@@ -554,17 +607,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
_currentBarcode = scanResult.Barcode; _currentBarcode = scanResult.Barcode;
_alarmCode = AlarmCode.None;
} }
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
WorkflowState.CheckingSftp, WorkflowState.CheckingSftp,
$"扫码成功: {scanResult.Barcode}", $"扫码成功: {scanResult.Barcode}",
state => _ => { },
{
state.ScanOk = true;
state.ScanNg = false;
},
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
_appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true); _appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true);
return scanResult.Barcode; return scanResult.Barcode;
@@ -576,29 +624,20 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
WorkflowState.ScanRetrying, WorkflowState.ScanRetrying,
$"扫码失败,等待第 {attempt + 1} 次重试", $"扫码失败,等待第 {attempt + 1} 次重试",
state => _ => { },
{
state.ScanOk = false;
state.ScanNg = false;
},
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
continue; continue;
} }
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
_resultCode = WorkflowResultCode.ScanFailedReleased; _resultCode = WorkflowResultCode.NgReleased;
_alarmCode = _config.Andon.EnableScanFailAlarm ? AlarmCode.ScanFailed : AlarmCode.None;
} }
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
WorkflowState.ScanFailedReleased, WorkflowState.ScanFailedReleased,
"扫码连续失败 3 次,进入报警放行", "扫码连续失败 3 次,进入报警放行",
state => _ => { },
{
state.ScanOk = false;
state.ScanNg = true;
},
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
await RaiseAlarmIfNeededAsync( await RaiseAlarmIfNeededAsync(
@@ -643,7 +682,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证"); snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证");
snapshot.SftpTryCount = _sftpTryCount;
}); });
if (outcome.Exists) if (outcome.Exists)
@@ -651,17 +689,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
_resultCode = WorkflowResultCode.Passed; _resultCode = WorkflowResultCode.Passed;
_alarmCode = AlarmCode.None;
} }
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
WorkflowState.SftpPassed, WorkflowState.SftpPassed,
$"文件已找到: {outcome.MatchedFilePath}", $"文件已找到: {outcome.MatchedFilePath}",
state => _ => { },
{
state.FileFound = true;
state.FileNotFound = false;
},
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
_appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true); _appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true);
return; return;
@@ -669,13 +702,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
if (outcome.IsConfigurationError) 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; return;
} }
if (outcome.IsSystemError) 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; return;
} }
@@ -693,18 +726,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
_resultCode = WorkflowResultCode.FileNotFoundReleased; _resultCode = WorkflowResultCode.NgReleased;
_alarmCode = _config.Andon.EnableFileNotFoundAlarm ? AlarmCode.FileNotFound : AlarmCode.None;
} }
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
WorkflowState.SftpTimeoutReleased, WorkflowState.SftpTimeoutReleased,
"文件未找到超时,按规则放行", "文件未找到超时,按规则放行",
state => _ => { },
{
state.FileFound = false;
state.FileNotFound = true;
},
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
_appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true); _appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true);
@@ -787,7 +815,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
state => state =>
{ {
state.ReleasePermit = false; state.ReleasePermit = false;
state.ProcessDone = true;
state.PcBusy = false; state.PcBusy = false;
}, },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -795,7 +822,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
var completedAt = DateTimeOffset.Now; var completedAt = DateTimeOffset.Now;
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.ProcessDone = true;
snapshot.IsBusy = false; snapshot.IsBusy = false;
snapshot.LastCompletedAt = completedAt; snapshot.LastCompletedAt = completedAt;
}); });
@@ -813,7 +839,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
ResultCode = (ushort)_resultCode, ResultCode = (ushort)_resultCode,
ResultDescription = GetResultDescription(_resultCode), ResultDescription = GetResultDescription(_resultCode),
ReleaseSent = _releaseSent, ReleaseSent = _releaseSent,
AlarmRaised = _plcState.AlarmRaised, AlarmRaised = false,
ExceptionSummary = _faultLatched ? "流程故障" : string.Empty ExceptionSummary = _faultLatched ? "流程故障" : string.Empty
}; };
} }
@@ -836,7 +862,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
_faultLatched = true; _faultLatched = true;
_resultCode = resultCode; _resultCode = resultCode;
_alarmCode = alarmCode;
} }
try try
@@ -846,10 +871,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
faultMessage, faultMessage,
state => state =>
{ {
state.SystemFault = true;
state.PcBusy = false; state.PcBusy = false;
state.ReleasePermit = false; state.ReleasePermit = false;
state.ProcessDone = false;
}, },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
@@ -861,7 +884,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_appLogger.LogError($"系统进入故障状态: {faultMessage}", true); _appLogger.LogError($"系统进入故障状态: {faultMessage}", true);
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.SystemFault = true;
snapshot.FaultMessage = faultMessage; snapshot.FaultMessage = faultMessage;
snapshot.ResultDescription = faultMessage; snapshot.ResultDescription = faultMessage;
}); });
@@ -878,22 +900,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
if (!enabled) if (!enabled)
{ {
await WritePlcStateAsync(state => state.AlarmRaised = false, cancellationToken).ConfigureAwait(false);
UpdateSnapshot(snapshot =>
{
snapshot.AlarmRaised = false;
snapshot.AlarmCode = 0;
});
return; return;
} }
var result = await _andonService.RaiseAlarmAsync(request, cancellationToken).ConfigureAwait(false); var result = await _andonService.RaiseAlarmAsync(request, cancellationToken).ConfigureAwait(false);
await WritePlcStateAsync(state => state.AlarmRaised = result.IsSuccess, cancellationToken).ConfigureAwait(false);
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
snapshot.AndonStatus = result.IsSuccess ? "调用成功" : $"调用失败: {result.ErrorMessage}"; snapshot.AndonStatus = result.IsSuccess ? "调用成功" : $"调用失败: {result.ErrorMessage}";
snapshot.AlarmRaised = result.IsSuccess;
snapshot.AlarmCode = request.AlarmCode;
}); });
if (result.IsSuccess) if (result.IsSuccess)
@@ -919,7 +932,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_scanTryCount = 0; _scanTryCount = 0;
_sftpTryCount = 0; _sftpTryCount = 0;
_resultCode = WorkflowResultCode.Processing; _resultCode = WorkflowResultCode.Processing;
_alarmCode = AlarmCode.None;
_faultLatched = false; _faultLatched = false;
} }
} }
@@ -933,16 +945,14 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
_faultLatched = false; _faultLatched = false;
_processingActive = false; _processingActive = false;
_lastPcbArrived = false; // PcbArrived 为 PLC 离散输入,软件复位后仍需等待其先回落再重新产生上升沿。
_releaseSent = false; _releaseSent = false;
_currentBoardStartedAt = null; _currentBoardStartedAt = null;
_currentBarcode = string.Empty; _currentBarcode = string.Empty;
_scanTryCount = 0; _scanTryCount = 0;
_sftpTryCount = 0; _sftpTryCount = 0;
_resultCode = WorkflowResultCode.None; _resultCode = WorkflowResultCode.None;
_alarmCode = AlarmCode.None;
ResetResultBits(_plcState); ResetResultBits(_plcState);
_plcState.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
} }
} }
@@ -953,18 +963,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
private static void ResetResultBits(PlcProcessState state) private static void ResetResultBits(PlcProcessState state)
{ {
state.PcBusy = false; state.PcBusy = false;
state.ScanOk = false;
state.ScanNg = false;
state.FileFound = false;
state.FileNotFound = false;
state.AlarmRaised = false;
state.ReleasePermit = false; state.ReleasePermit = false;
state.ProcessDone = false;
state.SystemFault = false;
state.ResultCode = 0; state.ResultCode = 0;
state.ScanTryCount = 0;
state.SftpTryCount = 0;
state.AlarmCode = 0;
} }
/// <summary> /// <summary>
@@ -977,11 +977,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
/// <returns>表示写入完成的任务。</returns> /// <returns>表示写入完成的任务。</returns>
private async Task ApplyProcessStateAsync(WorkflowState state, string description, Action<PlcProcessState> extraUpdate, CancellationToken cancellationToken) private async Task ApplyProcessStateAsync(WorkflowState state, string description, Action<PlcProcessState> extraUpdate, CancellationToken cancellationToken)
{ {
await WritePlcStateAsync(plcState => await WritePlcStateAsync(extraUpdate, cancellationToken).ConfigureAwait(false);
{
plcState.FlowStateCode = state.ToFlowStateCode();
extraUpdate(plcState);
}, cancellationToken).ConfigureAwait(false);
PublishRuntimeState(state, description); PublishRuntimeState(state, description);
} }
@@ -1001,9 +997,6 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
updateAction(_plcState); updateAction(_plcState);
_plcState.ResultCode = (ushort)_resultCode; _plcState.ResultCode = (ushort)_resultCode;
_plcState.ScanTryCount = checked((ushort)_scanTryCount);
_plcState.SftpTryCount = checked((ushort)_sftpTryCount);
_plcState.AlarmCode = (ushort)_alarmCode;
snapshot = _plcState.Clone(); 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> /// <summary>
/// 发布当前运行态快照到 UI 存储。 /// 发布当前运行态快照到 UI 存储。
/// </summary> /// </summary>
@@ -1051,29 +1017,17 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
DateTimeOffset? startedAt; DateTimeOffset? startedAt;
string barcode; string barcode;
int scanTryCount;
int sftpTryCount;
ushort resultCode; ushort resultCode;
ushort alarmCode;
bool isBusy; bool isBusy;
bool processDone;
bool systemFault;
bool alarmRaised;
string faultMessage; string faultMessage;
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
startedAt = _currentBoardStartedAt; startedAt = _currentBoardStartedAt;
barcode = _currentBarcode; barcode = _currentBarcode;
scanTryCount = _scanTryCount;
sftpTryCount = _sftpTryCount;
resultCode = (ushort)_resultCode; resultCode = (ushort)_resultCode;
alarmCode = (ushort)_alarmCode;
isBusy = _plcState.PcBusy; isBusy = _plcState.PcBusy;
processDone = _plcState.ProcessDone; faultMessage = _faultLatched ? description : string.Empty;
systemFault = _plcState.SystemFault;
alarmRaised = _plcState.AlarmRaised;
faultMessage = systemFault ? description : string.Empty;
} }
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
@@ -1083,14 +1037,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
snapshot.CurrentBarcode = barcode; snapshot.CurrentBarcode = barcode;
snapshot.ResultDescription = description; snapshot.ResultDescription = description;
snapshot.FaultMessage = faultMessage; snapshot.FaultMessage = faultMessage;
snapshot.ScanTryCount = scanTryCount;
snapshot.SftpTryCount = sftpTryCount;
snapshot.ResultCode = resultCode; snapshot.ResultCode = resultCode;
snapshot.AlarmCode = alarmCode;
snapshot.IsBusy = isBusy; snapshot.IsBusy = isBusy;
snapshot.ProcessDone = processDone;
snapshot.SystemFault = systemFault;
snapshot.AlarmRaised = alarmRaised;
if (startedAt.HasValue) if (startedAt.HasValue)
{ {
snapshot.LastTriggeredAt = startedAt; snapshot.LastTriggeredAt = startedAt;
@@ -1131,6 +1079,44 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_stateStore.UpdateSnapshot(action); _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>
/// 根据结果代码获取中文描述。 /// 根据结果代码获取中文描述。
/// </summary> /// </summary>
@@ -1143,13 +1129,8 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
WorkflowResultCode.None => "空闲 / 无结果", WorkflowResultCode.None => "空闲 / 无结果",
WorkflowResultCode.Processing => "处理中", WorkflowResultCode.Processing => "处理中",
WorkflowResultCode.Passed => "扫码成功,文件存在,正常放行", WorkflowResultCode.Passed => "扫码成功,文件存在,正常放行",
WorkflowResultCode.ScanFailedReleased => "扫码失败 3 次后放行", WorkflowResultCode.NgReleased => "处理完成,结果 NG后放行",
WorkflowResultCode.FileNotFoundReleased => "扫码成功,文件未找到超时放行", WorkflowResultCode.Fault => "系统故障",
WorkflowResultCode.PlcCommunicationFault => "PLC 通信异常",
WorkflowResultCode.ScannerFault => "串口异常",
WorkflowResultCode.SftpFault => "SFTP 连接或认证异常",
WorkflowResultCode.AndonFault => "安灯接口调用异常",
WorkflowResultCode.ConfigurationFault => "配置异常",
_ => "未知结果" _ => "未知结果"
}; };
} }

View File

@@ -50,45 +50,45 @@ public interface IAppLogger<TCategoryName>
/// 记录一条信息日志。 /// 记录一条信息日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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>
/// 记录一条警告日志。 /// 记录一条警告日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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>
/// 记录一条带异常的警告日志。 /// 记录一条带异常的警告日志。
/// </summary> /// </summary>
/// <param name="exception">异常对象。</param> /// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</param> /// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception> /// <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>
/// 记录一条错误日志。 /// 记录一条错误日志。
/// </summary> /// </summary>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</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>
/// 记录一条带异常的错误日志。 /// 记录一条带异常的错误日志。
/// </summary> /// </summary>
/// <param name="exception">异常对象。</param> /// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param> /// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param> /// <param name="showInUi">是否同步显示到前台日志区域。默认 <c>true</c>。</param>
/// <param name="args">日志模板参数。</param> /// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception> /// <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> /// <summary>
@@ -103,6 +103,13 @@ public interface IPlcService
/// <returns>输入信号快照。</returns> /// <returns>输入信号快照。</returns>
Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken); Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken);
/// <summary>
/// 读取 PLC 当前监控镜像,包括输入、输出和寄存器的实际值。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>监控镜像快照。</returns>
Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken);
/// <summary> /// <summary>
/// 写入 PLC 输出状态与寄存器值。 /// 写入 PLC 输出状态与寄存器值。
/// </summary> /// </summary>

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Hosting;
namespace AxiOmron.PcbCheck.Utils;
/// <summary>
/// 提供应用退出阶段的异步宿主停止辅助能力,确保 await 后续逻辑保留在捕获到的同步上下文中。
/// </summary>
internal static class ShutdownHelper
{
/// <summary>
/// 停止指定 Host并在停止完成后于捕获的调用上下文中执行收尾回调。
/// </summary>
/// <param name="host">待停止的宿主实例。</param>
/// <param name="timeout">停止超时时间。</param>
/// <param name="afterStop">停止完成后的收尾回调。</param>
/// <returns>表示停止流程完成的任务。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="host"/> 为空时抛出。</exception>
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();
}
}

View File

@@ -54,6 +54,7 @@ public partial class MainWindowViewModel : ObservableObject
Title = "Axi Omron PCB Check"; Title = "Axi Omron PCB Check";
Logs = new ObservableCollection<UiLogEntry>(); Logs = new ObservableCollection<UiLogEntry>();
RecentBoards = new ObservableCollection<BoardProcessRecord>(); RecentBoards = new ObservableCollection<BoardProcessRecord>();
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
AdminUnlockStatus = "管理员功能已锁定"; AdminUnlockStatus = "管理员功能已锁定";
Logs.CollectionChanged += OnLogsCollectionChanged; Logs.CollectionChanged += OnLogsCollectionChanged;
@@ -77,6 +78,11 @@ public partial class MainWindowViewModel : ObservableObject
/// </summary> /// </summary>
public ObservableCollection<BoardProcessRecord> RecentBoards { get; } public ObservableCollection<BoardProcessRecord> RecentBoards { get; }
/// <summary>
/// 获取 PLC 实时监控集合。
/// </summary>
public ObservableCollection<PlcMonitorItem> PlcMonitorItems { get; }
/// <summary> /// <summary>
/// 获取最近运行日志集合(与 <see cref="Logs"/> 为同一集合,供 UI 绑定语义更清晰)。 /// 获取最近运行日志集合(与 <see cref="Logs"/> 为同一集合,供 UI 绑定语义更清晰)。
/// </summary> /// </summary>
@@ -141,30 +147,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _faultMessage = string.Empty; private string _faultMessage = string.Empty;
/// <summary>
/// 获取或设置扫码次数。
/// </summary>
[ObservableProperty]
private int _scanTryCount;
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
[ObservableProperty]
private int _sftpTryCount;
/// <summary> /// <summary>
/// 获取或设置结果代码。 /// 获取或设置结果代码。
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
private ushort _resultCode; private ushort _resultCode;
/// <summary>
/// 获取或设置报警代码。
/// </summary>
[ObservableProperty]
private ushort _alarmCode;
/// <summary> /// <summary>
/// 获取或设置最近触发时间。 /// 获取或设置最近触发时间。
/// </summary> /// </summary>
@@ -183,24 +171,6 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isBusy; private bool _isBusy;
/// <summary>
/// 获取或设置是否存在系统故障。
/// </summary>
[ObservableProperty]
private bool _isFaulted;
/// <summary>
/// 获取或设置是否已完成。
/// </summary>
[ObservableProperty]
private bool _isDone;
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
[ObservableProperty]
private bool _isAlarmRaised;
/// <summary> /// <summary>
/// 获取或设置最近更新时间。 /// 获取或设置最近更新时间。
/// </summary> /// </summary>
@@ -219,12 +189,6 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private int _todayErrorCount; private int _todayErrorCount;
/// <summary>
/// 获取或设置当前活跃告警数量。
/// </summary>
[ObservableProperty]
private int _activeAlarmCount;
/// <summary> /// <summary>
/// 获取或设置最近一次异常日志的时间文本。 /// 获取或设置最近一次异常日志的时间文本。
/// </summary> /// </summary>
@@ -430,27 +394,17 @@ public partial class MainWindowViewModel : ObservableObject
CurrentBarcode = snapshot.CurrentBarcode; CurrentBarcode = snapshot.CurrentBarcode;
ResultDescription = snapshot.ResultDescription; ResultDescription = snapshot.ResultDescription;
FaultMessage = snapshot.FaultMessage; FaultMessage = snapshot.FaultMessage;
ScanTryCount = snapshot.ScanTryCount;
SftpTryCount = snapshot.SftpTryCount;
ResultCode = snapshot.ResultCode; ResultCode = snapshot.ResultCode;
AlarmCode = snapshot.AlarmCode;
LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
IsBusy = snapshot.IsBusy; 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"); LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary> PlcMonitorItems.Clear();
/// 当 <see cref="IsAlarmRaised"/> 发生变化时同步活跃告警计数。 foreach (PlcMonitorItem item in snapshot.PlcMonitorItems)
/// </summary> {
/// <param name="value">最新告警状态。</param> PlcMonitorItems.Add(item.Clone());
partial void OnIsAlarmRaisedChanged(bool value) }
{
ActiveAlarmCount = value ? 1 : 0;
} }
/// <summary> /// <summary>

View File

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Models;
using System.Collections.ObjectModel;
namespace AxiOmron.PcbCheck.ViewModels; namespace AxiOmron.PcbCheck.ViewModels;
@@ -13,21 +14,39 @@ public partial class SystemSettingViewModel : ObservableObject
{ {
private readonly IAppConfigService _appConfigService; private readonly IAppConfigService _appConfigService;
private readonly ISftpLookupService _sftpLookupService; private readonly ISftpLookupService _sftpLookupService;
private readonly IAppStateStore _appStateStore;
private readonly IDispatcherService _dispatcherService;
/// <summary> /// <summary>
/// 初始化系统设置视图模型。 /// 初始化系统设置视图模型。
/// </summary> /// </summary>
/// <param name="appConfigService">配置读写服务。</param> /// <param name="appConfigService">配置读写服务。</param>
/// <param name="sftpLookupService">SFTP 连接测试服务。</param> /// <param name="sftpLookupService">SFTP 连接测试服务。</param>
public SystemSettingViewModel(IAppConfigService appConfigService, ISftpLookupService sftpLookupService) /// <param name="appStateStore">运行态快照存储。</param>
/// <param name="dispatcherService">UI 线程调度服务。</param>
public SystemSettingViewModel(
IAppConfigService appConfigService,
ISftpLookupService sftpLookupService,
IAppStateStore appStateStore,
IDispatcherService dispatcherService)
{ {
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
_sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService)); _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService));
_appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore));
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
EditableConfig = _appConfigService.Load(); EditableConfig = _appConfigService.Load();
ConfigPath = _appConfigService.GetConfigPath(); ConfigPath = _appConfigService.GetConfigPath();
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
ApplySnapshot(_appStateStore.GetSnapshot());
_appStateStore.SnapshotChanged += OnSnapshotChanged;
StatusMessage = "已加载配置。"; StatusMessage = "已加载配置。";
} }
/// <summary>
/// 获取 PLC 实时监控集合。
/// </summary>
public ObservableCollection<PlcMonitorItem> PlcMonitorItems { get; }
/// <summary> /// <summary>
/// 获取或设置可编辑配置对象。 /// 获取或设置可编辑配置对象。
/// </summary> /// </summary>
@@ -116,4 +135,27 @@ public partial class SystemSettingViewModel : ObservableObject
/// 判断当前是否允许执行 SFTP 测试连接。 /// 判断当前是否允许执行 SFTP 测试连接。
/// </summary> /// </summary>
private bool CanTestSftpConnection => !IsTestingSftpConnection; private bool CanTestSftpConnection => !IsTestingSftpConnection;
/// <summary>
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
/// </summary>
/// <param name="snapshot">运行态快照。</param>
private void ApplySnapshot(RuntimeSnapshot snapshot)
{
PlcMonitorItems.Clear();
foreach (PlcMonitorItem item in snapshot.PlcMonitorItems)
{
PlcMonitorItems.Add(item.Clone());
}
}
/// <summary>
/// 处理运行态快照变更事件,刷新 PLC 监控集合。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="snapshot">最新快照。</param>
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
{
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
}
} }

View File

@@ -407,6 +407,7 @@
<Setter Property="AutoGenerateColumns" Value="False" /> <Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="IsReadOnly" Value="True" /> <Setter Property="IsReadOnly" Value="True" />
<Setter Property="SelectionMode" Value="Single" /> <Setter Property="SelectionMode" Value="Single" />
<Setter Property="ClipboardCopyMode" Value="IncludeHeader" />
<Setter Property="RowStyle" Value="{StaticResource ModernDataGridRowStyle}" /> <Setter Property="RowStyle" Value="{StaticResource ModernDataGridRowStyle}" />
<Setter Property="CellStyle" Value="{StaticResource ModernDataGridCellStyle}" /> <Setter Property="CellStyle" Value="{StaticResource ModernDataGridCellStyle}" />
<Setter Property="ColumnHeaderStyle" Value="{StaticResource ModernDataGridColumnHeaderStyle}" /> <Setter Property="ColumnHeaderStyle" Value="{StaticResource ModernDataGridColumnHeaderStyle}" />
@@ -497,7 +498,7 @@
<ColumnDefinition Width="1*" /> <ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="120" />
<RowDefinition Height="10" /> <RowDefinition Height="10" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
@@ -597,7 +598,7 @@
</StackPanel> </StackPanel>
<TextBlock Grid.Row="2" <TextBlock Grid.Row="2"
Text="{Binding AndonStatus}" Text="{Binding AndonStatus}" FontSize="10"
Style="{StaticResource CardPrimaryValueStyle}" /> Style="{StaticResource CardPrimaryValueStyle}" />
</Grid> </Grid>
</Border> </Border>
@@ -690,8 +691,8 @@
<Border Grid.Column="2" Style="{StaticResource CompactSummaryBlockStyle}"> <Border Grid.Column="2" Style="{StaticResource CompactSummaryBlockStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="报警码" Style="{StaticResource SummaryCaptionStyle}" /> <TextBlock Text="忙碌状态" Style="{StaticResource SummaryCaptionStyle}" />
<TextBlock Text="{Binding AlarmCode}" <TextBlock Text="{Binding IsBusy}"
Style="{StaticResource SummaryNumberStyle}" Style="{StaticResource SummaryNumberStyle}"
FontSize="18" /> FontSize="18" />
</StackPanel> </StackPanel>
@@ -713,8 +714,8 @@
<StackPanel Grid.Row="0" Orientation="Horizontal"> <StackPanel Grid.Row="0" Orientation="Horizontal">
<Border Style="{StaticResource AccentBarStyle}" /> <Border Style="{StaticResource AccentBarStyle}" />
<StackPanel VerticalAlignment="Center"> <StackPanel VerticalAlignment="Center">
<TextBlock Text="关键标志" Style="{StaticResource ModernCardTitleStyle}" /> <TextBlock Text="流程概览" Style="{StaticResource ModernCardTitleStyle}" />
<TextBlock Text="系统关键运行标志" Style="{StaticResource ModernCardSubtitleStyle}" /> <TextBlock Text="当前运行状态与关键信息" Style="{StaticResource ModernCardSubtitleStyle}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -724,49 +725,23 @@
<ColumnDefinition Width="12" /> <ColumnDefinition Width="12" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Grid.Column="0" Style="{StaticResource StatusFlagTileStyle}"> <Border Grid.Column="0" Style="{StaticResource StatusFlagTileStyle}">
<StackPanel Orientation="Horizontal"> <StackPanel>
<Ellipse Width="10" Height="10" <TextBlock Text="当前流程" Style="{StaticResource SummaryCaptionStyle}" />
Fill="{Binding IsBusy, Converter={StaticResource BooleanToBrushConverter}}" <TextBlock Text="{Binding WorkflowStateText}"
Margin="0,0,8,0" Style="{StaticResource SummaryNumberStyle}"
VerticalAlignment="Center" /> FontSize="18" />
<TextBlock Text="忙碌" Style="{StaticResource StatusIndicatorLabelStyle}" />
</StackPanel> </StackPanel>
</Border> </Border>
<Border Grid.Row="0" Grid.Column="2" Style="{StaticResource StatusFlagTileStyle}"> <Border Grid.Column="2" Style="{StaticResource StatusFlagTileStyle}">
<StackPanel Orientation="Horizontal"> <StackPanel>
<Ellipse Width="10" Height="10" <TextBlock Text="故障信息" Style="{StaticResource SummaryCaptionStyle}" />
Fill="{Binding IsDone, Converter={StaticResource BooleanToBrushConverter}}" <TextBlock Text="{Binding FaultMessage}"
Margin="0,0,8,0" Style="{StaticResource SummaryNumberStyle}"
VerticalAlignment="Center" /> FontSize="16"
<TextBlock Text="完成" Style="{StaticResource StatusIndicatorLabelStyle}" /> TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource StatusFlagTileStyle}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="10" Height="10"
Fill="{Binding IsFaulted, Converter={StaticResource BooleanToBrushConverter}}"
Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Text="故障" Style="{StaticResource StatusIndicatorLabelStyle}" />
</StackPanel>
</Border>
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource StatusFlagTileStyle}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="10" Height="10"
Fill="{Binding IsAlarmRaised, Converter={StaticResource BooleanToBrushConverter}}"
Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Text="报警" Style="{StaticResource StatusIndicatorLabelStyle}" />
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
@@ -780,19 +755,19 @@
<Border Grid.Column="0" Style="{StaticResource CompactSummaryBlockStyle}"> <Border Grid.Column="0" Style="{StaticResource CompactSummaryBlockStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="扫码次数" Style="{StaticResource SummaryCaptionStyle}" /> <TextBlock Text="最近触发" Style="{StaticResource SummaryCaptionStyle}" />
<TextBlock Text="{Binding ScanTryCount}" <TextBlock Text="{Binding LastTriggeredAt}"
Style="{StaticResource SummaryNumberStyle}" Style="{StaticResource SummaryNumberStyle}"
FontSize="18" /> FontSize="14" />
</StackPanel> </StackPanel>
</Border> </Border>
<Border Grid.Column="2" Style="{StaticResource CompactSummaryBlockStyle}"> <Border Grid.Column="2" Style="{StaticResource CompactSummaryBlockStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="SFTP 次数" Style="{StaticResource SummaryCaptionStyle}" /> <TextBlock Text="最近完成" Style="{StaticResource SummaryCaptionStyle}" />
<TextBlock Text="{Binding SftpTryCount}" <TextBlock Text="{Binding LastCompletedAt}"
Style="{StaticResource SummaryNumberStyle}" Style="{StaticResource SummaryNumberStyle}"
FontSize="18" /> FontSize="14" />
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
@@ -869,8 +844,8 @@
<Border Grid.Column="2" Style="{StaticResource SummaryBlockStyle}"> <Border Grid.Column="2" Style="{StaticResource SummaryBlockStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="当前活跃告警" Style="{StaticResource SummaryCaptionStyle}" /> <TextBlock Text="当前结果码" Style="{StaticResource SummaryCaptionStyle}" />
<TextBlock Text="{Binding ActiveAlarmCount}" Style="{StaticResource SummaryNumberStyle}" /> <TextBlock Text="{Binding ResultCode}" Style="{StaticResource SummaryNumberStyle}" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -907,10 +882,12 @@
<DataGridTemplateColumn Header="消息" Width="*"> <DataGridTemplateColumn Header="消息" Width="*">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Message}" <TextBox Text="{Binding Message}"
ToolTip="{Binding Message}" IsReadOnly="True"
TextTrimming="CharacterEllipsis" BorderThickness="0"
VerticalAlignment="Center" /> Background="Transparent"
ToolTip="{Binding Message}"
VerticalAlignment="Center" />
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>

View File

@@ -11,6 +11,39 @@
d:DataContext="{Binding Source={StaticResource DesignTimeLocator}, Path=SystemSettingViewModel}" d:DataContext="{Binding Source={StaticResource DesignTimeLocator}, Path=SystemSettingViewModel}"
DataContext="{Binding Source={StaticResource Locator}, Path=SystemSettingViewModel}"> DataContext="{Binding Source={StaticResource Locator}, Path=SystemSettingViewModel}">
<Page.Resources>
<Style x:Key="MonitorDataGridColumnHeaderStyle" TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#F2F5FA" />
<Setter Property="Foreground" Value="#4B5563" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
</Style>
<Style x:Key="MonitorDataGridStyle" TargetType="DataGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="RowBackground" Value="Transparent" />
<Setter Property="AlternatingRowBackground" Value="#FAFBFD" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HorizontalGridLinesBrush" Value="#EEF1F6" />
<Setter Property="VerticalGridLinesBrush" Value="Transparent" />
<Setter Property="ColumnHeaderHeight" Value="40" />
<Setter Property="RowHeight" Value="42" />
<Setter Property="CanUserResizeRows" Value="False" />
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="SelectionMode" Value="Single" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ColumnHeaderStyle" Value="{StaticResource MonitorDataGridColumnHeaderStyle}" />
</Style>
</Page.Resources>
<Grid Margin="16"> <Grid Margin="16">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="*" /> <RowDefinition Height="*" />
@@ -18,76 +51,123 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TabControl Grid.Row="0" Style="{StaticResource TabControlInLine}"> <TabControl Grid.Row="0" Style="{StaticResource TabControlInLine}">
<TabItem Header="PLC"> <TabItem Header="基础设置">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <TabControl Style="{StaticResource TabControlInLine}">
<StackPanel Margin="16"> <TabItem Header="PLC">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="PLC 主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Host, UpdateSourceTrigger=PropertyChanged}" /> <ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Port, UpdateSourceTrigger=PropertyChanged}" /> <StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="从站号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.UnitId, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="PLC 主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Host, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="轮询周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.PollIntervalMs, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Port, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="从站号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.UnitId, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="心跳周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.HeartbeatIntervalMs, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="轮询周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.PollIntervalMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行脉冲(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleasePulseMs, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行应答超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleaseAckTimeoutMs, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行脉冲(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleasePulseMs, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行应答超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleaseAckTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
</ScrollViewer> </StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="扫码枪">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="串口号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.PortName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="波特率" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.BaudRate, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="数据位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.DataBits, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="校验位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Parity, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="停止位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.StopBits, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ReadTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="触发命令" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.TriggerCommand, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="返回结束符" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ResponseTerminator, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="SFTP">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="用户名" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Username, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="密码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Password, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="私钥路径" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.PrivateKeyPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RootPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Style="{StaticResource ButtonDefault}"
Padding="18,8"
MinHeight="36"
Content="测试连接"
Command="{Binding TestSftpConnectionCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="安灯 &amp; 流程">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<CheckBox Content="启用安灯接口" IsChecked="{Binding EditableConfig.Andon.Enable}" Margin="0,6,0,0" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯 URL" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Url, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="请求方法" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Method, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.TimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯主题" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.AndonTheme, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="设备 EID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Eid, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="来源标记" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.FromSign, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="服务 ID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.ServiceId, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox Content="扫码失败时报警" IsChecked="{Binding EditableConfig.Andon.EnableScanFailAlarm}" Margin="0,6,0,0" />
<CheckBox Content="文件未找到时报警" IsChecked="{Binding EditableConfig.Andon.EnableFileNotFoundAlarm}" Margin="0,6,0,0" />
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</TabItem> </TabItem>
<TabItem Header="扫码枪"> <TabItem Header="PLC监控">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <Grid Margin="16">
<StackPanel Margin="16"> <Grid.RowDefinitions>
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="串口号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.PortName, UpdateSourceTrigger=PropertyChanged}" /> <RowDefinition Height="Auto" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="波特率" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.BaudRate, UpdateSourceTrigger=PropertyChanged}" /> <RowDefinition Height="12" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="数据位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.DataBits, UpdateSourceTrigger=PropertyChanged}" /> <RowDefinition Height="*" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="校验位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Parity, UpdateSourceTrigger=PropertyChanged}" /> </Grid.RowDefinitions>
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="停止位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.StopBits, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ReadTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="触发命令" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.TriggerCommand, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="返回结束符" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ResponseTerminator, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="SFTP"> <StackPanel Grid.Row="0">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <TextBlock Text="PLC 实时变量监控" FontSize="18" FontWeight="SemiBold" Foreground="#1F2937" />
<StackPanel Margin="16"> <TextBlock Text="显示 PLC 当前实际回读值,不使用应用内部缓存。"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" /> Margin="0,4,0,0"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" /> Foreground="#6B7280" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="用户名" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Username, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="密码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Password, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="私钥路径" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.PrivateKeyPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RootPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Style="{StaticResource ButtonDefault}"
Padding="18,8"
MinHeight="36"
Content="测试连接"
Command="{Binding TestSftpConnectionCommand}" />
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="安灯 &amp; 流程"> <Border Grid.Row="2"
<ScrollViewer VerticalScrollBarVisibility="Auto"> Background="White"
<StackPanel Margin="16"> BorderBrush="#E8ECF3"
<CheckBox Content="启用安灯接口" IsChecked="{Binding EditableConfig.Andon.Enable}" Margin="0,6,0,0" /> BorderThickness="1"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯 URL" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Url, UpdateSourceTrigger=PropertyChanged}" /> CornerRadius="10"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="请求方法" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Method, UpdateSourceTrigger=PropertyChanged}" /> Padding="8">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.TimeoutMs, UpdateSourceTrigger=PropertyChanged}" /> <DataGrid ItemsSource="{Binding PlcMonitorItems}"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯主题" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.AndonTheme, UpdateSourceTrigger=PropertyChanged}" /> Style="{StaticResource MonitorDataGridStyle}"
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="设备 EID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Eid, UpdateSourceTrigger=PropertyChanged}" /> CanUserSortColumns="False">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="来源标记" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.FromSign, UpdateSourceTrigger=PropertyChanged}" /> <DataGrid.Columns>
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="服务 ID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.ServiceId, UpdateSourceTrigger=PropertyChanged}" /> <DataGridTextColumn Header="变量名"
<CheckBox Content="扫码失败时报警" IsChecked="{Binding EditableConfig.Andon.EnableScanFailAlarm}" Margin="0,6,0,0" /> Binding="{Binding Name}"
<CheckBox Content="文件未找到时报警" IsChecked="{Binding EditableConfig.Andon.EnableFileNotFoundAlarm}" Margin="0,6,0,0" /> Width="1.2*" />
<CheckBox Content="要求 PLC Ready" IsChecked="{Binding EditableConfig.Workflow.RequirePlcReady}" Margin="0,6,0,0" /> <DataGridTextColumn Header="地址"
<CheckBox Content="要求 AutoMode &amp; StationEnable" IsChecked="{Binding EditableConfig.Workflow.RequireAutoMode}" Margin="0,6,0,0" /> Binding="{Binding Address}"
</StackPanel> Width="0.9*" />
</ScrollViewer> <DataGridTextColumn Header="分组"
Binding="{Binding GroupName}"
Width="0.9*" />
<DataGridTextColumn Header="当前值"
Binding="{Binding CurrentValue}"
Width="0.9*" />
<DataGridTextColumn Header="最近更新时间"
Binding="{Binding LastUpdatedAt, StringFormat=yyyy-MM-dd HH:mm:ss}"
Width="1.4*" />
</DataGrid.Columns>
</DataGrid>
</Border>
</Grid>
</TabItem> </TabItem>
</TabControl> </TabControl>

View File

@@ -5,35 +5,19 @@
"UnitId": 1, "UnitId": 1,
"PollIntervalMs": 200, "PollIntervalMs": 200,
"ConnectTimeoutMs": 3000, "ConnectTimeoutMs": 3000,
"HeartbeatIntervalMs": 500,
"ReleasePulseMs": 500, "ReleasePulseMs": 500,
"ReleaseAckTimeoutMs": 2000, "ReleaseAckTimeoutMs": 2000,
"Inputs": { "Inputs": {
"PlcReady": 10001, "PcbArrived": 0,
"PcbArrived": 10002, "PlcReset": 1,
"PlcReset": 10003, "PlcAckRelease": 2
"PlcAckRelease": 10004,
"AutoMode": 10005,
"StationEnable": 10006
}, },
"Outputs": { "Outputs": {
"PcOnline": 51, "PcBusy": 51,
"PcBusy": 52, "ReleasePermit": 52
"ScanOk": 53,
"ScanNg": 54,
"FileFound": 55,
"FileNotFound": 56,
"AlarmRaised": 57,
"ReleasePermit": 58,
"ProcessDone": 59,
"SystemFault": 60
}, },
"Registers": { "Registers": {
"ResultCode": 40001, "ResultCode": 0
"ScanTryCount": 40002,
"SftpTryCount": 40003,
"AlarmCode": 40004,
"FlowStateCode": 40005
} }
}, },
"Scanner": { "Scanner": {
@@ -76,9 +60,6 @@
} }
}, },
"Workflow": { "Workflow": {
"RequirePlcReady": true,
"RequireAutoMode": true,
"RequireStationEnable": true,
"RequireManualResetAfterFault": true, "RequireManualResetAfterFault": true,
"MaxUiLogEntries": 200, "MaxUiLogEntries": 200,
"MaxBoardRecords": 100 "MaxBoardRecords": 100

View File

@@ -0,0 +1,149 @@
using System.Collections.Concurrent;
using AxiOmron.PcbCheck.Utils;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证应用退出阶段的异步清理流程不会丢失原始线程上下文。
/// </summary>
public sealed class ShutdownHelperTests
{
/// <summary>
/// 停止 Host 后,后续收尾回调应继续在捕获到的同步上下文线程上执行。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task StopHostAsync_ShouldResumeOnCapturedSynchronizationContext()
{
await SingleThreadSynchronizationContext.RunAsync(async () =>
{
int expectedThreadId = Environment.CurrentManagedThreadId;
int callbackThreadId = -1;
var host = new FakeHost();
await ShutdownHelper.StopHostAsync(
host,
TimeSpan.FromMilliseconds(200),
() => callbackThreadId = Environment.CurrentManagedThreadId);
Assert.True(host.StopAsyncCalled);
Assert.Equal(expectedThreadId, callbackThreadId);
});
}
private sealed class FakeHost : IHost
{
public IServiceProvider Services => throw new NotSupportedException();
public bool StopAsyncCalled { get; private set; }
public void Dispose()
{
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
StopAsyncCalled = true;
await Task.Yield();
}
}
/// <summary>
/// 提供仅在单线程内串行泵送回调的同步上下文,便于验证 await 后的线程恢复行为。
/// </summary>
private sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
/// <summary>
/// 在专用线程同步上下文中执行异步委托。
/// </summary>
/// <param name="action">待执行的异步委托。</param>
/// <returns>表示执行完成的任务。</returns>
public static Task RunAsync(Func<Task> action)
{
ArgumentNullException.ThrowIfNull(action);
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
var context = new SingleThreadSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(context);
Task task;
try
{
task = action();
}
catch (Exception ex)
{
completionSource.SetException(ex);
return;
}
task.ContinueWith(
completedTask =>
{
if (completedTask.IsFaulted)
{
completionSource.SetException(completedTask.Exception!.InnerExceptions);
}
else if (completedTask.IsCanceled)
{
completionSource.SetCanceled();
}
else
{
completionSource.SetResult();
}
context._queue.CompleteAdding();
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
foreach ((SendOrPostCallback callback, object? state) in context._queue.GetConsumingEnumerable())
{
callback(state);
}
})
{
IsBackground = true
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return completionSource.Task;
}
/// <summary>
/// 将委托异步排入当前上下文队列。
/// </summary>
/// <param name="d">待执行回调。</param>
/// <param name="state">状态对象。</param>
public override void Post(SendOrPostCallback d, object? state)
{
ArgumentNullException.ThrowIfNull(d);
_queue.Add((d, state));
}
/// <summary>
/// 在当前线程直接执行同步回调。
/// </summary>
/// <param name="d">待执行回调。</param>
/// <param name="state">状态对象。</param>
public override void Send(SendOrPostCallback d, object? state)
{
ArgumentNullException.ThrowIfNull(d);
d(state);
}
}
}

View File

@@ -1,7 +1,12 @@
using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.ViewModels; using AxiOmron.PcbCheck.ViewModels;
using System.Runtime.ExceptionServices;
using System.Windows.Data;
using System.Windows.Threading;
using Xunit;
namespace AxiOmron.PcbCheck.Tests; namespace AxiOmron.PcbCheck.Tests;
@@ -18,6 +23,8 @@ public sealed class SystemSettingViewModelTests
public async Task TestSftpConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds() public async Task TestSftpConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
{ {
var configService = new FakeAppConfigService(); var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var sftpLookupService = new FakeSftpLookupService var sftpLookupService = new FakeSftpLookupService
{ {
TestOutcome = new SftpConnectionTestOutcome TestOutcome = new SftpConnectionTestOutcome
@@ -27,7 +34,7 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接成功,根目录可访问。" StatusMessage = "SFTP 连接成功,根目录可访问。"
} }
}; };
var viewModel = new SystemSettingViewModel(configService, sftpLookupService); var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null); await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
@@ -42,6 +49,8 @@ public sealed class SystemSettingViewModelTests
public async Task TestSftpConnectionAsync_ShouldReportFailure_WhenConnectionFails() public async Task TestSftpConnectionAsync_ShouldReportFailure_WhenConnectionFails()
{ {
var configService = new FakeAppConfigService(); var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var sftpLookupService = new FakeSftpLookupService var sftpLookupService = new FakeSftpLookupService
{ {
TestOutcome = new SftpConnectionTestOutcome TestOutcome = new SftpConnectionTestOutcome
@@ -51,13 +60,167 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接失败: timeout" StatusMessage = "SFTP 连接失败: timeout"
} }
}; };
var viewModel = new SystemSettingViewModel(configService, sftpLookupService); var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null); await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage); Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage);
} }
/// <summary>
/// 当运行态快照包含 PLC 监控项时,应同步到系统设置页监控列表。
/// </summary>
[Fact]
public void Constructor_ShouldLoadPlcMonitorItems_FromStateStoreSnapshot()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
stateStore.UpdateSnapshot(snapshot =>
{
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
{
GroupName = "Inputs",
Name = "PcbArrived",
CurrentValue = "True",
LastUpdatedAt = DateTimeOffset.Now
});
});
var viewModel = new SystemSettingViewModel(configService, new FakeSftpLookupService(), stateStore, dispatcherService);
Assert.Single(viewModel.PlcMonitorItems);
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
}
/// <summary>
/// 当后台线程发布运行态快照时,不应直接在后台线程修改已绑定到 CollectionView 的 PLC 监控集合。
/// </summary>
[Fact]
public void UpdateSnapshot_ShouldNotThrow_WhenSnapshotChangesFromBackgroundThread()
{
Exception? backgroundException = RunInSta(() =>
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
var viewModel = new SystemSettingViewModel(configService, new FakeSftpLookupService(), stateStore, dispatcherService);
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
Exception? capturedException = null;
using var completed = new ManualResetEventSlim(false);
var workerThread = new Thread(() =>
{
try
{
stateStore.UpdateSnapshot(snapshot =>
{
snapshot.PlcMonitorItems.Clear();
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
{
GroupName = "Inputs",
Name = "PcbArrived",
CurrentValue = "True",
LastUpdatedAt = DateTimeOffset.Now
});
});
}
catch (Exception ex)
{
capturedException = ex;
}
finally
{
completed.Set();
}
});
workerThread.IsBackground = true;
workerThread.Start();
Assert.True(completed.Wait(TimeSpan.FromSeconds(5)), "后台线程未在预期时间内完成快照更新。");
workerThread.Join();
PumpDispatcherUntil(() => viewModel.PlcMonitorItems.Count == 1, TimeSpan.FromSeconds(5));
Assert.Single(viewModel.PlcMonitorItems);
return capturedException;
});
Assert.Null(backgroundException);
}
/// <summary>
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
/// </summary>
/// <typeparam name="T">委托返回值类型。</typeparam>
/// <param name="action">待执行的委托。</param>
/// <returns>委托返回结果。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
private static T RunInSta<T>(Func<T> action)
{
ArgumentNullException.ThrowIfNull(action);
T? result = default;
Exception? capturedException = null;
using var completed = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try
{
result = action();
}
catch (Exception ex)
{
capturedException = ex;
}
finally
{
completed.Set();
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(completed.Wait(TimeSpan.FromSeconds(5)), "STA 测试线程未在预期时间内完成。");
thread.Join();
if (capturedException is not null)
{
ExceptionDispatchInfo.Capture(capturedException).Throw();
}
return result!;
}
/// <summary>
/// 在当前 STA 线程中循环处理 Dispatcher 队列,直到满足指定条件。
/// </summary>
/// <param name="condition">停止等待的条件。</param>
/// <param name="timeout">最大等待时长。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="condition"/> 为 <see langword="null"/> 时抛出。</exception>
/// <exception cref="TimeoutException">当超时仍未满足条件时抛出。</exception>
private static void PumpDispatcherUntil(Func<bool> condition, TimeSpan timeout)
{
ArgumentNullException.ThrowIfNull(condition);
DateTime deadline = DateTime.UtcNow.Add(timeout);
while (!condition())
{
if (DateTime.UtcNow > deadline)
{
throw new TimeoutException("Dispatcher 队列在预期时间内未完成处理。");
}
var frame = new DispatcherFrame();
_ = Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, new DispatcherOperationCallback(_ =>
{
frame.Continue = false;
return null;
}), null);
Dispatcher.PushFrame(frame);
}
}
private sealed class FakeAppConfigService : IAppConfigService private sealed class FakeAppConfigService : IAppConfigService
{ {
public AppConfig Config { get; } = new(); public AppConfig Config { get; } = new();
@@ -92,4 +255,36 @@ public sealed class SystemSettingViewModelTests
return Task.FromResult(TestOutcome); return Task.FromResult(TestOutcome);
} }
} }
/// <summary>
/// 提供立即执行的测试用 Dispatcher实现与设计时调度一致的同步行为。
/// </summary>
private sealed class ImmediateDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
return Task.CompletedTask;
}
}
/// <summary>
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
/// </summary>
private sealed class CapturedDispatcherService : IDispatcherService
{
private readonly Dispatcher _dispatcher;
public CapturedDispatcherService(Dispatcher dispatcher)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
return _dispatcher.InvokeAsync(action).Task;
}
}
} }

View File

@@ -2,6 +2,7 @@ using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Implementations;
using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Services.Interfaces;
using Xunit;
namespace AxiOmron.PcbCheck.Tests; namespace AxiOmron.PcbCheck.Tests;
@@ -40,13 +41,312 @@ public sealed class WorkflowHostedServiceTests
Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus); Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
} }
/// <summary>
/// 启动探活 PLC 成功时,应立即将 PLC 状态更新为已连接。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ProbePlcOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionSucceeds()
{
var stateStore = new AppStateStore();
var plcService = new ProbingPlcService();
var service = new WorkflowHostedService(
plcService,
new FakeScannerService(),
new FakeSftpLookupService(),
new FakeAndonService(),
stateStore,
new AppConfig(),
new FakeAppLogger<WorkflowHostedService>());
await service.ProbePlcOnStartupAsync(CancellationToken.None);
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
Assert.Equal("已连接", snapshot.PlcStatus);
Assert.True(plcService.ForceReconnectCalled);
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "True");
}
/// <summary>
/// 启动探活 PLC 失败时,应将状态更新为连接失败而不是保留默认未连接。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ProbePlcOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails()
{
var stateStore = new AppStateStore();
var service = new WorkflowHostedService(
new FailingPlcService(),
new FakeScannerService(),
new FakeSftpLookupService(),
new FakeAndonService(),
stateStore,
new AppConfig(),
new FakeAppLogger<WorkflowHostedService>());
await service.ProbePlcOnStartupAsync(CancellationToken.None);
Assert.StartsWith("连接失败:", stateStore.GetSnapshot().PlcStatus);
}
/// <summary>
/// 当 PLC 仅提供到位信号时,流程仍应启动,不再依赖就绪、自动模式和工位使能位。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ExecuteAsync_ShouldStartWorkflow_WhenOnlyPcbArrivedIsProvided()
{
var stateStore = new AppStateStore();
var plcService = new SequencedPlcService(new[]
{
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true }
});
var scannerService = new CountingScannerService
{
Result = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-001"
}
};
var sftpService = new FakeSftpLookupService
{
CheckOutcome = new SftpCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = "/pcb/PCB-001.txt"
}
};
var service = new WorkflowHostedService(
plcService,
scannerService,
sftpService,
new FakeAndonService(),
stateStore,
new AppConfig
{
Plc = new PlcOptions
{
PollIntervalMs = 20,
ReleaseAckTimeoutMs = 100,
ReleasePulseMs = 10
},
Scanner = new ScannerOptions
{
MaxScanAttempts = 1
},
Sftp = new SftpOptions
{
MaxRetryCount = 0
}
},
new FakeAppLogger<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.True(scannerService.TriggerCount > 0);
Assert.Contains(plcService.WrittenStates, state => state.ReleasePermit);
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "False");
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcBusy" && item.CurrentValue == "False");
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((ushort)WorkflowResultCode.Passed).ToString());
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
}
/// <summary>
/// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ResetAsync_ShouldNotRestartWorkflow_WhenPcbArrivedRemainsHigh()
{
var stateStore = new AppStateStore();
var plcService = new SequencedPlcService(new[]
{
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }
});
var scannerService = new CountingScannerService
{
Result = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-EDGE-001"
}
};
var service = new WorkflowHostedService(
plcService,
scannerService,
new FakeSftpLookupService
{
CheckOutcome = new SftpCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = "/pcb/PCB-EDGE-001.txt"
}
},
new FakeAndonService(),
stateStore,
new AppConfig
{
Plc = new PlcOptions
{
PollIntervalMs = 20,
ReleaseAckTimeoutMs = 100,
ReleasePulseMs = 10
},
Scanner = new ScannerOptions
{
MaxScanAttempts = 1
},
Sftp = new SftpOptions
{
MaxRetryCount = 0
}
},
new FakeAppLogger<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(120);
await service.ResetAsync(CancellationToken.None);
await Task.Delay(120);
await service.StopAsync(CancellationToken.None);
Assert.Equal(1, scannerService.TriggerCount);
}
private sealed class FakePlcService : IPlcService private sealed class FakePlcService : IPlcService
{ {
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot()); public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask; public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
} }
private sealed class ProbingPlcService : IPlcService
{
public bool ForceReconnectCalled { get; private set; }
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
ForceReconnectCalled = true;
return Task.CompletedTask;
}
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcMonitorSnapshot
{
Inputs = new PlcSignalSnapshot
{
PcbArrived = true,
PlcReset = false,
PlcAckRelease = false,
CapturedAt = DateTimeOffset.Now
},
Outputs = new PlcProcessState
{
PcBusy = false,
ReleasePermit = false,
ResultCode = 0
},
CapturedAt = DateTimeOffset.Now
});
}
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class FailingPlcService : IPlcService
{
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("PLC unreachable");
}
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("PLC unreachable");
}
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("PLC unreachable");
}
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class SequencedPlcService : IPlcService
{
private readonly PlcSignalSnapshot[] _signals;
private int _index;
public SequencedPlcService(PlcSignalSnapshot[] signals)
{
_signals = signals;
}
public List<PlcProcessState> WrittenStates { get; } = new();
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
PlcSignalSnapshot current = _signals[Math.Min(Math.Max(_index - 1, 0), _signals.Length - 1)];
PlcProcessState lastWritten = WrittenStates.Count > 0 ? WrittenStates[^1] : new PlcProcessState();
return Task.FromResult(new PlcMonitorSnapshot
{
Inputs = new PlcSignalSnapshot
{
PcbArrived = current.PcbArrived,
PlcReset = current.PlcReset,
PlcAckRelease = current.PlcAckRelease,
CapturedAt = DateTimeOffset.Now
},
Outputs = lastWritten.Clone(),
CapturedAt = DateTimeOffset.Now
});
}
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
var current = _signals[Math.Min(_index, _signals.Length - 1)];
_index++;
return Task.FromResult(current);
}
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
WrittenStates.Add(state.Clone());
return Task.CompletedTask;
}
}
private sealed class FakeScannerService : IScannerService private sealed class FakeScannerService : IScannerService
{ {
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -54,11 +354,30 @@ public sealed class WorkflowHostedServiceTests
public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken) => Task.FromResult(new ScanOperationResult()); public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken) => Task.FromResult(new ScanOperationResult());
} }
private sealed class CountingScannerService : IScannerService
{
public int TriggerCount { get; private set; }
public ScanOperationResult Result { get; set; } = new();
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<bool> TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true);
public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken)
{
TriggerCount++;
return Task.FromResult(Result);
}
}
private sealed class FakeSftpLookupService : ISftpLookupService private sealed class FakeSftpLookupService : ISftpLookupService
{ {
public SftpConnectionTestOutcome TestOutcome { get; set; } = new(); public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(new SftpCheckOutcome()); public SftpCheckOutcome CheckOutcome { get; set; } = new();
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(CheckOutcome);
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{ {