From ce356bffffe8a73270201d36f3eff3b9e982cab6 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Sun, 17 May 2026 14:08:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(*):=20=E9=87=8D=E6=9E=84PCB?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E6=B5=81=E7=A8=8B=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=8F=8C=E6=89=AB=E7=A0=81=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验 - 新增文件校验与扫码结果汇总实现,补充对应单测 - 同步更新流程设计文档、配置和系统设置页面文案 --- .claude/settings.local.json | 3 +- docs/2026-04-16-pcb-check-flow-design.md | 1032 ++++---- docs/2026-05-17-pcb-check-final-workflow.md | 132 + .../2026-04-19-csv-record-persistence.md | 406 +++ ...026-04-19-csv-record-persistence-design.md | 378 +++ docs/正式的工作流程 | 149 ++ src/AxiOmron.PcbCheck/App.xaml.cs | 8 +- .../AxiOmron.PcbCheck.csproj | 1 - .../BooleanToVisibilityConverter.cs | 134 +- .../ResultCodeToTagTextConverter.cs | 108 +- .../DesignTime/DesignTimeAppConfigService.cs | 39 +- .../DesignTime/DesignTimeSftpLookupService.cs | 18 +- .../Models/RuntimeSnapshot.cs | 2 +- .../Models/WorkflowModels.cs | 24 +- src/AxiOmron.PcbCheck/Options/AppConfig.cs | 151 +- .../Implementations/AppConfigService.cs | 7 +- .../DualScannerResultMerger.cs | 97 + .../Implementations/FileCheckPathMatcher.cs | 35 + .../Implementations/FileCheckService.cs | 91 + .../Implementations/LocalFileCheckProvider.cs | 179 ++ ...cpPlcService.cs => OmronFinsPlcService.cs} | 120 +- .../Implementations/SerialScannerService.cs | 319 --- .../Implementations/SftpLookupService.cs | 97 +- .../Implementations/TcpDualScannerService.cs | 244 ++ .../Implementations/WorkflowHostedService.cs | 178 +- .../Services/Interfaces/CoreInterfaces.cs | 42 +- .../ViewModels/MainWindowViewModel.cs | 2 +- .../ViewModels/SystemSettingViewModel.cs | 41 +- .../Views/Pages/DashboardPage.xaml | 2270 ++++++++--------- .../Views/Pages/SystemSettingsPage.xaml | 41 +- .../appConfig.Development.json | 23 +- src/AxiOmron.PcbCheck/appConfig.json | 46 +- .../AppConfigServiceTests.cs | 48 + .../DualScannerResultMergerTests.cs | 64 + .../LocalFileCheckProviderTests.cs | 183 ++ .../SystemSettingViewModelTests.cs | 40 +- .../WorkflowHostedServiceTests.cs | 127 +- 37 files changed, 4443 insertions(+), 2436 deletions(-) create mode 100644 docs/2026-05-17-pcb-check-final-workflow.md create mode 100644 docs/superpowers/plans/2026-04-19-csv-record-persistence.md create mode 100644 docs/superpowers/specs/2026-04-19-csv-record-persistence-design.md create mode 100644 docs/正式的工作流程 create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/DualScannerResultMerger.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs rename src/AxiOmron.PcbCheck/Services/Implementations/{ModbusTcpPlcService.cs => OmronFinsPlcService.cs} (65%) delete mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/AppConfigServiceTests.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/DualScannerResultMergerTests.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7c93c9a..3b538bf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,8 @@ "Bash(mkdir -p /tmp/pcb)", "Read(//tmp/**)", "Bash(taskkill /F /IM \"AxiOmron.PcbCheck.exe\")", - "Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")" + "Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")", + "Bash(dotnet publish *)" ] } } diff --git a/docs/2026-04-16-pcb-check-flow-design.md b/docs/2026-04-16-pcb-check-flow-design.md index ece1f6e..0ccaf11 100644 --- a/docs/2026-04-16-pcb-check-flow-design.md +++ b/docs/2026-04-16-pcb-check-flow-design.md @@ -1,13 +1,17 @@ -# PCB 目检软件需求与方案设计 +# PCB 目检放行软件需求与方案设计 ## 1. 文档目的 -本文档用于整理当前 PCB 简易目检上位机的软件需求、业务流程、通信接口、异常处理和 Modbus TCP 点位设计,作为后续开发、联调和现场验收的依据。 +本文档用于整理当前 PCB 目检放行上位机的软件需求、业务流程、通信接口、异常处理和 PLC 点位设计,作为后续开发、联调和现场验收的依据。 -当前版本仅覆盖以下能力: +当前定稿流程以 `docs/正式的工作流程` 为 PLC 与扫码枪通信依据,并保留旧流程设计中已经确认的 SFTP 文件校验、安灯报警、日志追溯和系统异常处理规则。 -- PLC 到位信号接入 -- 串口触发扫码枪扫码 +当前版本覆盖以下能力: + +- PLC 到位、复位、放行确认信号接入 +- 上位机通过 OMRON FINS 读取和写入 PLC 点位 +- 两把 TCP 扫码枪触发扫码 +- 扫码结果汇总与条码选择 - SFTP 文件存在性校验 - PLC 放行信号回写 - HTTP 安灯报警接口调用 @@ -27,214 +31,323 @@ ### 2.1 系统角色 -系统由以下四个外部对象和一个上位机应用组成: +系统由以下外部对象和一个上位机应用组成: 1. **PLC** - - 通过 Modbus TCP 与上位机通信 - - 提供 PCB 到位、复位、运行允许等状态 - - 接收上位机放行、流程完成、故障/报警等结果信号 + - 通过 OMRON FINS 与上位机通信 + - 提供 PCB 到位、复位、放行确认等状态 + - 接收上位机忙碌、放行和最终结果信号 -2. **扫码枪** - - 通过串口与上位机通信 - - 接收上位机触发扫描指令 - - 返回二维码内容或超时失败 +2. **扫码枪1** + - 通过 TCP Socket 与上位机通信 + - 接收上位机触发扫描命令 `T` + - 返回二维码或条码字符串 -3. **SFTP 服务器** +3. **扫码枪2** + - 通过 TCP Socket 与上位机通信 + - 接收上位机触发扫描命令 `T` + - 返回二维码或条码字符串 + +4. **SFTP 服务器** - 用于存放与 PCB 二维码 ID 对应的判定文件或放行文件 - 上位机根据扫码结果访问指定目录并查找目标文件 -4. **安灯系统** +5. **安灯系统** - 通过 HTTP 接口接收报警请求 - 用于扫码失败等异常场景的现场告警 -5. **上位机软件** - - 负责流程编排、设备通信、状态机控制、日志记录、异常处理和 PLC 结果回写 +6. **上位机软件** + - 负责流程编排、设备通信、状态控制、日志记录、异常处理和 PLC 结果回写 ### 2.2 运行约束 -- 运行模式为**单件串行处理** -- 同一时刻只处理一块 PCB -- 上位机作为 **Modbus TCP Client** -- PLC 作为 **Modbus TCP Server** -- 扫码最多尝试 **3 次(含首次触发)** -- SFTP 采用 **1 次首次查询 + 最多 N 次重试** 的方式检查目标文件 -- 扫码失败 3 次后仍 **放行**,同时触发安灯报警 -- SFTP 文件最终未找到时仍 **放行**,默认仅记录日志,不强制安灯报警 -- SFTP 连接失败、认证失败、目录配置错误等**系统级异常**不等同于“文件不存在”,默认进入系统故障,不自动放行 +- 运行模式为**单件串行处理**。 +- 同一时刻只处理一块 PCB。 +- PLC 与上位机之间使用 **OMRON FINS**。 +- 上位机与扫码枪之间使用 **TCP Socket**。 +- 两把扫码枪的触发命令均为 `T`。 +- 扫码结果采用“任一成功即可”的规则进入 SFTP 校验。 +- 两把扫码枪都成功但条码内容不一致时,优先使用扫码枪1条码,并记录不一致日志。 +- 两把扫码枪均失败时,上位机调用安灯报警接口,并按 NG 放行。 +- SFTP 采用 **1 次首次查询 + 最多 N 次重试** 的方式检查目标文件。 +- SFTP 文件最终未找到时仍按 NG 放行,默认仅记录日志,不强制安灯报警。 +- SFTP 连接失败、认证失败、目录配置错误等**系统级异常**不等同于“文件不存在”,默认进入系统故障,不自动放行。 --- -## 3. 需求整理 +## 3. 系统通信结构 -### 3.1 功能需求 +```text +PLC <--FINS--> C# 上位机 <--TCP--> 扫码枪1 + <--TCP--> 扫码枪2 + <--SFTP--> SFTP 服务器 + <--HTTP--> 安灯系统 +``` -#### 3.1.1 到位触发 +### 3.1 PLC 与上位机 -- 当 PCB 到达指定工位后,PLC 通过 Modbus TCP 点位通知上位机“PCB 已到位” -- 上位机在检测到到位信号后,启动本次单板处理流程 -- 上位机应避免同一块板被重复触发处理 +- 通信协议:OMRON FINS +- 上位机读取 PLC 点位 +- 上位机写入 PLC 点位 +- PLC 负责提供到位、复位、放行确认信号 +- 上位机负责写入忙碌、放行脉冲和最终结果码 -#### 3.1.2 扫码控制 +### 3.2 上位机与扫码枪 -- 上位机通过串口向扫码枪发送触发命令 -- 扫码枪返回二维码字符串 -- 如果单次扫码失败,上位机允许继续重试 -- 当前版本最大尝试次数固定为 **3 次(含首次触发)** +- 通信协议:TCP Socket +- 扫码枪数量:2 把 +- 发送触发命令:`T` +- 接收扫码结果:二维码或条码字符串 +- 任一扫码枪返回有效条码即可继续后续 SFTP 校验 -#### 3.1.3 扫码失败处理 +### 3.3 上位机与 SFTP -- 若连续 3 次扫码失败: - - 上位机调用 HTTP 安灯报警接口 - - 上位机记录失败原因和报警结果 - - 上位机仍向 PLC 发送放行信号 - - 本次流程结果标记为“扫码失败放行” +- 上位机根据最终选定条码拼接或匹配目标文件名 +- 文件存在表示可以正常放行 +- 文件未找到超时表示按 NG 放行 +- SFTP 系统异常表示上位机能力不可用,应进入系统故障 -#### 3.1.4 SFTP 文件校验 +### 3.4 上位机与安灯 -- 扫码成功后,上位机根据二维码 ID 到配置好的 SFTP 目录中查找对应文件 -- 当前版本采用 **首次查询 1 次 + 文件未命中后最多重试 N 次** 的规则,`N` 为可配置项 +- 扫码失败时调用安灯报警接口 +- 文件未找到报警保留为可配置扩展 +- 安灯接口调用失败不阻断既定放行流程,但必须记录日志 + +--- + +## 4. PLC 通信点位定义 + +### 4.1 PLC -> 上位机(上位机读取) + +| 地址 | 名称 | 类型 | 说明 | +| --- | --- | --- | --- | +| D1001 | PcbArrived | Bool | PCB 已到位,请求上位机处理 | +| D1002 | PlcReset | Bool | PLC 请求上位机清状态/复位 | +| D1003 | PlcAckRelease | Bool | PLC 已接收到放行信号,可选 | + +预留点位: + +| 地址范围 | 说明 | +| --- | --- | +| D1004 ~ D1010 | PLC -> 上位机扩展信号预留 | + +说明: + +- `PcbArrived` 为本流程主触发点。 +- `PlcReset` 用于人工清故障、恢复空闲态或清除保持位。 +- `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消。 + +### 4.2 上位机 -> PLC(上位机写入) + +| 地址 | 名称 | 类型 | 说明 | +| --- | --- | --- | --- | +| D1051 | PcBusy | Bool | 上位机正在处理当前 PCB | +| D1052 | ReleasePermit | Bool | 放行脉冲信号 | + +说明: + +- `PcBusy = 1` 表示当前板卡流程尚未结束。 +- `ReleasePermit` 采用脉冲输出,不建议长时间保持。 + +### 4.3 结果寄存器(上位机写入) + +| 地址 | 名称 | 类型 | 说明 | +| --- | --- | --- | --- | +| D1100 | ResultCode | Int | 本次最终结果代码 | + +### 4.4 ResultCode 定义 + +| 数值 | 含义 | +| --- | --- | +| 0 | Idle | +| 1 | 处理中 | +| 10 | 正常放行 | +| 20 | NG 放行 | +| 90 | 系统故障 | + +### 4.5 点位清理策略 + +- 新板开始时写入 `D1051 PcBusy = 1`、`D1100 ResultCode = 1`。 +- 放行时写入 `D1052 ReleasePermit = 1`。 +- PLC 若启用放行确认,则上位机等待 `D1003 PlcAckRelease = 1`。 +- PLC 进入复位流程时写入 `D1002 PlcReset = 1`。 +- 上位机收到复位后清理 `D1051 PcBusy = 0`、`D1052 ReleasePermit = 0`、`D1100 ResultCode = 0`。 + +--- + +## 5. 功能需求 + +### 5.1 到位触发 + +- 当 PCB 到达指定工位后,PLC 写入 `D1001 PcbArrived = 1`。 +- 上位机通过 FINS 读取到位信号。 +- 上位机在检测到到位信号后,启动本次单板处理流程。 +- 上位机应避免同一块板被重复触发处理。 + +### 5.2 双扫码枪控制 + +- 上位机通过 TCP Socket 向扫码枪1发送 `T`。 +- 上位机通过 TCP Socket 向扫码枪2发送 `T`。 +- 两把扫码枪可以同时触发,也可以按实现限制依次触发,但同一块板必须汇总两路结果后再判定。 +- 每把扫码枪都应具备独立连接、独立超时和独立错误日志。 + +### 5.3 扫码结果汇总 + +- 任一扫码枪返回有效条码,即视为扫码汇总成功。 +- 仅扫码枪1成功时,使用扫码枪1条码进入 SFTP 校验。 +- 仅扫码枪2成功时,使用扫码枪2条码进入 SFTP 校验。 +- 两把扫码枪都成功且条码一致时,使用该条码进入 SFTP 校验。 +- 两把扫码枪都成功但条码不一致时,优先使用扫码枪1条码进入 SFTP 校验,并记录扫码枪2条码和不一致日志。 +- 两把扫码枪均失败时,视为本次扫码失败。 + +### 5.4 扫码失败处理 + +- 若两把扫码枪均未返回有效条码: + - 上位机调用 HTTP 安灯报警接口。 + - 上位机记录两把扫码枪的失败原因和报警结果。 + - 上位机写入 `D1100 ResultCode = 20`。 + - 上位机仍向 PLC 发送放行信号。 + - 本次流程结果标记为“扫码失败 NG 放行”。 + +### 5.5 SFTP 文件校验 + +- 扫码汇总成功后,上位机根据最终选定条码到配置好的 SFTP 目录中查找对应文件。 +- 当前版本采用 **首次查询 1 次 + 文件未命中后最多重试 N 次** 的规则,`N` 为可配置项。 - 若文件存在: - - 视为校验通过 - - 上位机立即发送放行信号给 PLC + - 视为校验通过。 + - 上位机写入 `D1100 ResultCode = 10`。 + - 上位机发送放行信号给 PLC。 - 若文件不存在: - - 等待 X 秒后再次检查 - - 达到重试上限后按“文件未找到超时处理”执行 + - 等待 X 秒后再次检查。 + - 达到重试上限后按“文件未找到超时处理”执行。 -#### 3.1.5 文件未找到超时处理 +### 5.6 文件未找到超时处理 - 若达到 SFTP 查询重试上限后仍未找到对应文件: - - 上位机记录“文件未找到超时放行” - - 上位机仍向 PLC 发送放行信号 - - 默认不强制调用安灯接口,但应保留后续扩展为可配置报警策略的设计空间 + - 上位机记录“文件未找到超时 NG 放行”。 + - 上位机写入 `D1100 ResultCode = 20`。 + - 上位机仍向 PLC 发送放行信号。 + - 默认不强制调用安灯接口,但保留后续扩展为可配置报警策略的设计空间。 -#### 3.1.6 二维码内容处理 +### 5.7 二维码内容处理 - 二维码字符串在用于 SFTP 查询前,应先执行基础清洗: - - 去除首尾空白字符 - - 去除回车换行等控制字符 -- 清洗后若为空字符串,则视为本次扫码失败 -- 当前版本不增加更复杂的正则或长度业务校验,避免引入未确认的规则 + - 去除首尾空白字符。 + - 去除回车换行等控制字符。 +- 清洗后若为空字符串,则视为对应扫码枪本次扫码失败。 +- 当前版本不增加更复杂的正则或长度业务校验,避免引入未确认的规则。 -#### 3.1.7 PLC 交互 +### 5.8 日志与追溯 -- 上位机需向 PLC 提供以下反馈能力: - - 忙碌状态 - - 当前流程完成状态 - - 放行信号 - - 扫码成功/失败状态 - - 文件存在/不存在状态 - - 故障或报警状态 - - 异常代码/结果代码 +上位机应记录以下关键日志: -#### 3.1.8 日志与追溯 - -- 上位机应记录以下关键日志: - - 到位触发时间 - - 扫码每次尝试结果 - - 最终二维码内容 - - SFTP 连接与查找结果 - - HTTP 报警调用结果 - - PLC 点位写入动作 - - 本次流程最终结果 - -### 3.2 非功能需求 - -- 支持稳定连续运行 -- 所有外部接口都应具备超时控制 -- 所有异常都必须记录日志,不允许静默吞掉 -- 同一时刻只能处理一块 PCB,避免并发混板 -- 配置项应集中管理,避免硬编码 IP、端口、账号、目录和重试参数 +- 到位触发时间 +- 扫码枪1触发、返回、超时、失败原因 +- 扫码枪2触发、返回、超时、失败原因 +- 双扫码枪汇总结果和最终选定条码 +- 双扫码枪条码不一致时的两路原始条码 +- SFTP 连接与查找结果 +- HTTP 报警调用结果 +- PLC 点位写入动作 +- 本次流程最终结果 --- -## 4. 推荐总体方案 - -### 4.1 总体架构 +## 6. 推荐总体方案 推荐采用**单工位串行状态机架构**。 该方案由一个主流程控制器串联以下模块: -- PLC 通信模块 -- 扫码枪控制模块 +- PLC FINS 通信模块 +- TCP 扫码枪1控制模块 +- TCP 扫码枪2控制模块 +- 双扫码结果汇总模块 - SFTP 校验模块 - 安灯报警模块 - 流程状态机模块 - 日志与结果记录模块 -### 4.2 方案特点 +方案特点: -- 适合当前“单件串行”场景 -- 逻辑清晰,便于现场联调 -- 点位数量可控,便于 PLC 程序实现 -- 后续可在不推翻主体结构的前提下扩展更多结果码、报警策略和 UI 状态展示 - -### 4.3 不推荐的当前方案 - -当前阶段不建议直接采用多任务队列、多工位并行或由 PLC 承担大部分业务编排的重型架构,因为会显著增加联调复杂度,不符合“简单目检软件”的目标。 +- 适合当前“单件串行”场景。 +- PLC 点位数量少,便于现场联调。 +- 两把扫码枪提高扫码成功率。 +- SFTP 文件校验和安灯报警继续保持原业务位置。 +- 后续可在不推翻主体结构的前提下扩展更多结果码、报警策略和 UI 状态展示。 --- -## 5. 软件模块设计 +## 7. 软件模块设计 -### 5.1 流程控制模块 +### 7.1 流程控制模块 负责: -- 接收 PLC 到位信号 -- 驱动整个业务状态机流转 -- 管理当前板的处理上下文 -- 决定何时触发扫码、何时检查文件、何时报警、何时放行 +- 接收 PLC 到位信号。 +- 驱动整个业务状态机流转。 +- 管理当前板的处理上下文。 +- 决定何时触发两把扫码枪、何时检查文件、何时报警、何时放行。 建议职责边界: -- 一个流程控制器只负责一块当前 PCB -- 所有外部服务都由流程控制器统一调度 -- 不允许 PLC、扫码、SFTP、报警模块彼此直接耦合调用 +- 一个流程控制器只负责一块当前 PCB。 +- 所有外部服务都由流程控制器统一调度。 +- PLC、扫码、SFTP、报警模块之间不直接耦合调用。 -### 5.2 PLC 通信模块 +### 7.2 PLC FINS 通信模块 负责: -- 周期性读取 PLC 点位 -- 写入上位机结果点位 -- 做好写脉冲、保持位和复位逻辑 -- 管理断线重连和通信状态 +- 周期性读取 PLC 点位。 +- 写入上位机结果点位。 +- 管理放行脉冲、忙碌保持位和复位逻辑。 +- 管理断线重连和通信状态。 建议接口: -- `ReadSignalsAsync()`:读取 PLC 输入状态 -- `WriteHandshakeAsync()`:写入握手和结果点位 -- `PulseReleaseAsync()`:发送放行脉冲 -- `ResetResultBitsAsync()`:清理本次流程结果位 +- `ReadSignalsAsync()`:读取 `D1001`、`D1002`、`D1003`。 +- `WriteProcessStateAsync()`:写入 `D1051`、`D1052`、`D1100`。 +- `PulseReleaseAsync()`:发送放行脉冲。 +- `ResetResultBitsAsync()`:清理本次流程结果位。 -### 5.3 扫码枪控制模块 +### 7.3 TCP 扫码枪控制模块 负责: -- 管理串口打开、关闭、重连 -- 发送扫码命令 -- 接收扫码返回值 -- 执行单次扫码超时控制 -- 将扫码结果以统一对象返回给流程控制器 +- 管理扫码枪 TCP 连接。 +- 发送扫码命令 `T`。 +- 接收扫码返回值。 +- 执行单把扫码枪的超时控制。 +- 将扫码结果以统一对象返回给流程控制器。 -建议输出统一结果: +建议每把扫码枪输出统一结果: - 是否成功 +- 扫码枪编号 - 扫码内容 - 原始返回报文 - 失败原因 - 本次耗时 -### 5.4 SFTP 校验模块 +### 7.4 双扫码结果汇总模块 负责: -- 根据配置建立 SFTP 连接 -- 按二维码 ID 查找目标文件 -- 支持按目录、文件名模板或通配方式查找 -- 执行重试、等待和超时控制 -- 输出最终查找结果 +- 汇总扫码枪1和扫码枪2结果。 +- 按“任一成功即可”规则判断是否进入 SFTP。 +- 按“扫码枪1优先”规则处理双成功但内容不一致场景。 +- 输出最终选定条码、两路原始结果和汇总说明。 + +### 7.5 SFTP 校验模块 + +负责: + +- 根据配置建立 SFTP 连接。 +- 按最终选定条码查找目标文件。 +- 支持按目录、文件名模板或通配方式查找。 +- 执行重试、等待和超时控制。 +- 输出最终查找结果。 建议支持的配置项: @@ -248,13 +361,13 @@ - 查询等待秒数 - 最大重试次数 -### 5.5 安灯报警模块 +### 7.6 安灯报警模块 负责: -- 调用 HTTP 安灯接口 -- 发送报警编码、报警内容、工位号、二维码等参数 -- 处理响应结果并记录日志 +- 调用 HTTP 安灯接口。 +- 发送报警编码、报警内容、工位号、二维码等参数。 +- 处理响应结果并记录日志。 建议支持的配置项: @@ -267,20 +380,21 @@ - 是否启用扫码失败报警 - 是否启用文件未找到报警(预留) -### 5.6 日志与结果记录模块 +### 7.7 日志与结果记录模块 负责: -- 写入运行日志 -- 保存每板处理结果摘要 -- 输出 UI 可显示的当前状态、结果和错误信息 +- 写入运行日志。 +- 保存每板处理结果摘要。 +- 输出 UI 可显示的当前状态、结果和错误信息。 建议记录字段: - 触发时间 - 完成时间 -- 条码内容 -- 扫码次数 +- 扫码枪1结果 +- 扫码枪2结果 +- 最终选定条码 - SFTP 重试次数 - 最终结果代码 - 最终结果描述 @@ -289,270 +403,252 @@ --- -## 6. 业务状态机设计 +## 8. 业务状态机设计 -### 6.1 状态定义 +### 8.1 状态定义 建议定义以下流程状态: 1. **Idle**:空闲,等待 PLC 到位 2. **Triggered**:接收到到位信号,准备启动流程 -3. **Scanning**:正在触发扫码枪扫码 -4. **ScanRetrying**:扫码失败,等待下一次扫码尝试 -5. **ScanFailedReleased**:扫码失败 3 次,已报警并决定放行 -6. **CheckingSftp**:正在检查 SFTP 文件是否存在 -7. **WaitingSftpRetry**:文件未找到,等待下一次轮询 -8. **SftpPassed**:文件找到,允许放行 -9. **SftpTimeoutReleased**:文件未找到超时,决定放行 -10. **Releasing**:向 PLC 发送放行信号 -11. **Completed**:本次流程结束 -12. **Faulted**:出现系统级故障,如 PLC 通信异常、配置错误、串口不可用、SFTP 连接异常等 +3. **ScanningScanner1**:正在触发扫码枪1 +4. **ScanningScanner2**:正在触发扫码枪2 +5. **MergingScanResult**:正在汇总双扫码结果 +6. **ScanFailedReleased**:双扫码均失败,已报警并决定 NG 放行 +7. **CheckingSftp**:正在检查 SFTP 文件是否存在 +8. **WaitingSftpRetry**:文件未找到,等待下一次轮询 +9. **SftpPassed**:文件找到,允许正常放行 +10. **SftpTimeoutReleased**:文件未找到超时,决定 NG 放行 +11. **Releasing**:向 PLC 发送放行信号 +12. **Completed**:本次流程结束 +13. **Faulted**:出现系统级故障,如 PLC 通信异常、配置错误、扫码枪连接异常、SFTP 连接异常等 -### 6.2 状态流转 +### 8.2 状态流转 #### 正常流转 -`Idle -> Triggered -> Scanning -> CheckingSftp -> SftpPassed -> Releasing -> Completed -> Idle` +`Idle -> Triggered -> ScanningScanner1/ScanningScanner2 -> MergingScanResult -> CheckingSftp -> SftpPassed -> Releasing -> Completed -> Idle` -#### 扫码失败放行流转 +#### 扫码失败 NG 放行流转 -`Idle -> Triggered -> Scanning -> ScanRetrying -> Scanning -> ScanRetrying -> Scanning -> ScanFailedReleased -> Releasing -> Completed -> Idle` +`Idle -> Triggered -> ScanningScanner1/ScanningScanner2 -> MergingScanResult -> ScanFailedReleased -> Releasing -> Completed -> Idle` -#### 文件未找到超时放行流转 +#### 文件未找到超时 NG 放行流转 -`Idle -> Triggered -> Scanning -> CheckingSftp -> WaitingSftpRetry -> CheckingSftp -> ... -> SftpTimeoutReleased -> Releasing -> Completed -> Idle` +`Idle -> Triggered -> ScanningScanner1/ScanningScanner2 -> MergingScanResult -> CheckingSftp -> WaitingSftpRetry -> CheckingSftp -> ... -> SftpTimeoutReleased -> Releasing -> Completed -> Idle` #### 系统故障流转 `任意流程态 -> Faulted -> 人工复位/PLC复位 -> Idle` -### 6.3 状态机约束 +### 8.3 状态机约束 -- 只有 `Idle` 状态才允许接收新的 PCB 到位触发 -- 系统进入 `Faulted` 后,不允许自动接收下一块板,必须在故障恢复后由人工复位或 PLC 复位解除 -- 允许后台自动执行断线重连,但在 `Faulted` 未解除前不得恢复业务处理 -- 每次开始新流程前必须清理上一板的过程结果位 -- `Release` 动作必须具备防重复发送保护 - -### 6.4 启动前置条件 - -系统只有在以下条件同时满足时,才视为处于可接板的 `Idle` 状态: - -- PLC 通信已建立 -- `PlcReady = 1` -- `AutoMode = 1` -- `StationEnable = 1` -- 上位机不存在未解除的 `SystemFault` - -若上述条件不满足,上位机仅保持监视,不启动新板流程。 +- 只有 `Idle` 状态才允许接收新的 PCB 到位触发。 +- 系统进入 `Faulted` 后,不允许自动接收下一块板,必须在故障恢复后由人工复位或 PLC 复位解除。 +- 允许后台自动执行断线重连,但在 `Faulted` 未解除前不得恢复业务处理。 +- 每次开始新流程前必须清理上一板的过程结果位。 +- `ReleasePermit` 动作必须具备防重复发送保护。 +- 两把扫码枪的单次失败不直接等同于本板失败,必须先执行汇总判定。 --- -## 7. 详细流程说明 +## 9. 详细流程说明 -### 7.1 主流程 +### 9.1 主流程 -1. 上位机轮询 PLC 到位点位 -2. 当检测到“到位”且当前状态为 `Idle` 时: - - 记录开始时间 - - 置 Busy 位 - - 清理上次结果位 - - 写入当前流程状态码 - - 进入扫码流程 -3. 扫码成功后,进入 SFTP 校验流程 -4. 根据 SFTP 结果决定立即放行或超时后放行 -5. 发送放行信号给 PLC -6. 置流程完成位,并将最终结果码保持为稳定值 -7. 等待 PLC 应答或到位信号撤销 -8. 回到空闲状态 +1. PLC 写入 `D1001 PcbArrived = 1`。 +2. 上位机通过 FINS 读取到位信号。 +3. 当检测到到位且当前状态为 `Idle` 时: + - 记录开始时间。 + - 写入 `D1051 PcBusy = 1`。 + - 写入 `D1100 ResultCode = 1`。 + - 清理上次过程结果。 + - 进入双扫码流程。 +4. 双扫码汇总成功后,进入 SFTP 校验流程。 +5. 根据 SFTP 结果决定正常放行、NG 放行或系统故障。 +6. 正常放行或 NG 放行时,写入最终 `ResultCode` 并发送 `D1052 ReleasePermit = 1`。 +7. 若启用 `D1003 PlcAckRelease`,上位机等待 PLC 放行确认。 +8. PLC 写入 `D1002 PlcReset = 1` 后,上位机执行复位。 +9. 上位机写入 `D1051 = 0`、`D1052 = 0`、`D1100 = 0`,回到空闲状态。 -### 7.2 扫码流程 +### 9.2 双扫码流程 -1. 发送扫码枪触发指令 -2. 等待扫码结果,单次扫码应设置超时 -3. 对扫码结果进行基础清洗:去掉空白和控制字符 -4. 若成功: - - 保存二维码内容 - - 清除 `ScanNg` - - 置 `ScanOk` - - 写入当前流程状态码 - - 进入 SFTP 校验流程 -5. 若失败: - - 累加尝试次数 - - 记录日志 - - 若未达到最大尝试次数,则进入下一轮扫码 -6. 达到 3 次后仍失败: - - 清除 `ScanOk` - - 置 `ScanNg` - - 调用安灯报警 - - 写入结果代码“扫码失败放行” - - 进入放行流程 +1. 上位机向扫码枪1发送 TCP 命令 `T`。 +2. 上位机向扫码枪2发送 TCP 命令 `T`。 +3. 分别等待两把扫码枪返回,单把扫码枪应设置独立超时。 +4. 对两路扫码结果分别执行基础清洗:去掉空白和控制字符。 +5. 若仅一路成功: + - 保存成功条码为最终选定条码。 + - 记录另一路失败原因。 + - 进入 SFTP 校验流程。 +6. 若两路都成功且条码一致: + - 保存该条码为最终选定条码。 + - 进入 SFTP 校验流程。 +7. 若两路都成功但条码不一致: + - 优先使用扫码枪1条码作为最终选定条码。 + - 记录扫码枪1条码、扫码枪2条码和不一致说明。 + - 进入 SFTP 校验流程。 +8. 若两路均失败: + - 调用安灯报警。 + - 写入 `D1100 ResultCode = 20`。 + - 进入放行流程。 -说明: +### 9.3 SFTP 校验流程 -- `ScanNg` 表示**最终扫码失败**,不用于表示中间某一次扫码失败 -- 当前版本“3 次”定义为**总共最多 3 次尝试**,不是“首次 1 次 + 额外重试 3 次” - -### 7.3 SFTP 校验流程 - -1. 按配置建立 SFTP 连接 -2. 根据二维码 ID 拼接目标文件名或查找规则 -3. 立即执行首次查询 +1. 按配置建立 SFTP 连接。 +2. 根据最终选定条码拼接目标文件名或查找规则。 +3. 立即执行首次查询。 4. 若文件存在: - - 清除 `FileNotFound` - - 置 `FileFound` - - 写入结果代码“文件存在放行” - - 进入放行流程 + - 写入 `D1100 ResultCode = 10`。 + - 进入放行流程。 5. 若文件不存在: - - 记录当前查询未命中 - - 若未达到重试上限,则等待 X 秒再次查询 - - 若达到重试上限,则清除 `FileFound`、置 `FileNotFound`、写入结果代码“文件未找到超时放行”,进入放行流程 + - 记录当前查询未命中。 + - 若未达到重试上限,则等待 X 秒再次查询。 + - 若达到重试上限,则写入 `D1100 ResultCode = 20`,进入放行流程。 6. 若出现 SFTP 连接失败、认证失败、目录不存在等系统级异常: - - 置 `SystemFault` - - 写入故障结果码 - - 进入 `Faulted` + - 写入 `D1100 ResultCode = 90`。 + - 进入 `Faulted`。 说明: -- 当前版本 `MaxRetryCount = N` 表示**首次未命中后的最多重试次数** -- 因此总查询次数为 **1 + N** -- `FileNotFound` 表示**最终未找到**,不用于表示中间某次查询未命中 +- 当前版本 `MaxRetryCount = N` 表示**首次未命中后的最多重试次数**。 +- 因此总查询次数为 **1 + N**。 +- 文件未找到是业务 NG,SFTP 不可用是系统故障,两者必须区分。 -### 7.4 放行流程 +### 9.4 安灯报警流程 -1. 上位机向 PLC 写入放行请求位 `ReleasePermit` -2. 推荐采用**脉冲方式**输出放行信号,默认脉冲时长建议为 **500ms**,可配置 -3. 同时写入最终结果码、流程完成位和稳定的状态位 -4. 若 PLC 提供 `PlcAckRelease`: - - 上位机等待 PLC 应答 - - 最长等待时间建议为 **2000ms**,超时则记录告警并自动清除放行脉冲 -5. 若 PLC 不提供 `PlcAckRelease`: - - 上位机保持 `ReleasePermit` 至配置脉冲时长结束后自动清除 -6. `ProcessDone = 1` 时,表示 `ResultCode`、`AlarmCode`、`ScanTryCount`、`SftpTryCount` 均已为最终稳定值 +1. 双扫码均失败时,上位机调用安灯报警接口。 +2. 报警内容应包含工位、报警编码、报警描述、扫码枪1失败原因、扫码枪2失败原因和触发时间。 +3. 安灯接口调用成功或失败都必须记录日志。 +4. 安灯接口失败不阻断扫码失败 NG 放行。 +5. 文件未找到超时是否报警保留为配置策略,默认不强制报警。 + +### 9.5 放行与复位流程 + +1. 上位机写入最终 `D1100 ResultCode`: + - `10`:正常放行。 + - `20`:NG 放行。 +2. 上位机写入 `D1052 ReleasePermit = 1`。 +3. 若 PLC 提供 `D1003 PlcAckRelease`: + - 上位机等待 PLC 应答。 + - 超时则记录告警并按脉冲策略自动清除放行位。 +4. 若 PLC 不提供 `D1003 PlcAckRelease`: + - 上位机保持 `ReleasePermit` 至配置脉冲时长结束后自动清除。 +5. PLC 写入 `D1002 PlcReset = 1`。 +6. 上位机执行复位: + - 写入 `D1051 PcBusy = 0`。 + - 写入 `D1052 ReleasePermit = 0`。 + - 写入 `D1100 ResultCode = 0`。 --- -## 8. Modbus TCP 通信设计 +## 10. 工序流程图 -### 8.1 通信角色 +```mermaid +flowchart TD -- 上位机:Modbus TCP Client -- PLC:Modbus TCP Server -- 轮询周期建议:100ms ~ 300ms -- 布尔量优先使用位点位,结果码优先使用 Holding Register + A[PLC写入 D1001=PcbArrived=1] + --> B[C#上位机通过FINS读取到位信号] -### 8.2 点位设计原则 + B --> C[上位机写入 D1051=PcBusy=1] + B --> D[上位机写入 D1100=1 处理中] -- 输入点和输出点职责分离 -- 只保留流程闭环必需的少量点位 -- 详细错误原因由上位机日志与界面表达,不强依赖 PLC 点位细分 -- 放行信号使用脉冲,忙碌位使用保持方式 -- 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义 + D --> E[TCP发送 T 到扫码枪1] + D --> F[TCP发送 T 到扫码枪2] -### 8.3 PLC -> 上位机点位(建议) + E --> G[扫码枪1返回扫码结果] + F --> H[扫码枪2返回扫码结果] -以下点位由 PLC 提供,上位机读取: + G --> I[上位机汇总扫码结果] + H --> I -| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | -| --- | --- | --- | --- | --- | --- | -| 1 | Discrete Input | 10001 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 | -| 2 | Discrete Input | 10002 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 | -| 3 | Discrete Input | 10003 | PlcAckRelease | PLC -> PC | PLC 已接收到放行信号,可选 | + I --> J{是否至少一路扫码成功} -说明: + J -->|是| K[选择最终条码] + J -->|否| L[调用安灯报警] -- `PcbArrived` 为本流程主触发点 -- `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消 -- `PlcReset` 用于人工清故障、恢复空闲态或清除保持位 -- `10004 ~ 10050` 预留给后续 PLC -> 上位机扩展信号 + K --> M[SFTP查找对应文件] + L --> N[写入 D1100=20 NG放行] -### 8.4 上位机 -> PLC 点位(建议) + M --> O{SFTP结果} + O -->|文件存在| P[写入 D1100=10 正常放行] + O -->|文件未找到超时| Q[写入 D1100=20 NG放行] + O -->|系统异常| R[写入 D1100=90 系统故障] -以下点位由上位机写入,PLC 读取: + P --> S[写入 D1052=ReleasePermit=1] + Q --> S + N --> S -| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 | -| --- | --- | --- | --- | --- | --- | -| 1 | Coil | 00051 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB | -| 2 | Coil | 00052 | ReleasePermit | PC -> PLC | 放行脉冲信号 | + S --> T[PLC收到放行信号] + T --> U{是否启用确认信号} -说明: + U -->|启用| V[PLC写入 D1003=PlcAckRelease=1] + U -->|不启用| W[进入复位流程] -- `ReleasePermit` 采用脉冲输出,不建议长时间保持 -- `PcBusy = 1` 表示当前板卡流程尚未结束 -- `ResultCode` 在流程进行中和结束后都可读取,不依赖额外完成位 + V --> W + W --> X[PLC写入 D1002=PlcReset=1] + X --> Y[上位机执行复位] + Y --> Z[写入 D1051=0, D1052=0, D1100=0 Idle] -### 8.5 结果寄存器设计(建议) - -建议仅保留一个 Holding Register 表达最终结果。 - -| 序号 | 地址类型 | 地址 | 名称 | 说明 | -| --- | --- | --- | --- | --- | -| 1 | Holding Register | 40001 | ResultCode | 本次最终结果代码 | - -### 8.6 推荐结果代码定义 - -| 代码 | 含义 | -| --- | --- | -| 0 | Idle / 无结果 | -| 1 | 处理中 | -| 10 | 扫码成功,文件存在,正常放行 | -| 20 | 本次处理结果 NG,但按规则放行 | -| 90 | 系统故障 | - -### 8.7 点位清理策略 - -建议按如下规则清理点位: - -- 新板开始前清理:`PcBusy`、`ReleasePermit`、`ResultCode` -- 流程处理中保持:`PcBusy` -- 放行时脉冲输出:`ReleasePermit` -- 流程结束后清理:`PcBusy` -- 故障恢复后在人工复位或 `PlcReset` 时清理 `ResultCode` + R --> AA[进入系统故障, 等待复位] +``` --- -## 9. 串口扫码设计建议 +## 11. TCP 扫码枪通信设计 -### 9.1 配置项 +### 11.1 扫码枪1 -建议配置以下参数: +#### 发送 -- 串口号 -- 波特率 -- 数据位 -- 校验位 -- 停止位 -- 单次扫码超时毫秒数 -- 触发命令 -- 结束符/返回报文规则 +```text +T +``` -### 9.2 抽象接口建议 +#### 接收 -扫码服务建议统一为: +```text +二维码/条码字符串 +``` -- `TriggerScanAsync()` -- 返回 `ScanResult` +### 11.2 扫码枪2 -`ScanResult` 建议包含: +#### 发送 -- `IsSuccess` -- `Barcode` -- `RawMessage` -- `ErrorMessage` -- `DurationMs` +```text +T +``` -### 9.3 行为建议 +#### 接收 -- 串口打开失败时立即记录系统故障 -- 每次扫码都必须单独设置超时 -- 返回空字符串、格式错误或无结束符都视为失败 -- 对扫码值仅做基础清洗,不额外加入未确认的业务规则 +```text +二维码/条码字符串 +``` + +### 11.3 配置项 + +建议每把扫码枪配置以下参数: + +- 名称 +- IP 地址 +- 端口 +- 连接超时毫秒数 +- 读取超时毫秒数 +- 触发命令,当前固定为 `T` +- 返回结束符或返回报文规则 + +### 11.4 行为建议 + +- TCP 连接失败时记录该扫码枪失败原因。 +- 单把扫码枪失败不直接判定整板失败,必须经过双扫码汇总。 +- 两把扫码枪均失败才进入扫码失败安灯报警和 NG 放行。 +- 返回空字符串或仅包含控制字符,视为该扫码枪失败。 --- -## 10. SFTP 文件查找设计建议 +## 12. SFTP 文件查找设计 -### 10.1 查找策略 +### 12.1 查找策略 建议支持以下任一方式,由配置指定: @@ -565,49 +661,37 @@ 初版推荐采用**完整文件名匹配**,规则最清晰,误判概率最低。 -### 10.2 条码到文件名映射 +### 12.2 条码到文件名映射 -- 当前版本以**清洗后的二维码字符串**作为查询键 -- 推荐默认文件名规则为:`${barcode}.txt` -- 实际文件名后缀或模板通过配置项 `FileNamePattern` 指定 -- 若后续现场规则变更,可通过配置调整,不修改流程主逻辑 +- 当前版本以**双扫码汇总后的最终选定条码**作为查询键。 +- 推荐默认文件名规则为:`${barcode}.txt`。 +- 实际文件名后缀或模板通过配置项 `FileNamePattern` 指定。 +- 若后续现场规则变更,可通过配置调整,不修改流程主逻辑。 -### 10.3 配置项 +### 12.3 行为要求 -- `Host` -- `Port` -- `Username` -- `Password` 或私钥 -- `RootPath` -- `FileNamePattern` -- `RetryIntervalSeconds` -- `MaxRetryCount` -- `ConnectTimeoutMs` - -### 10.4 行为建议 - -- 每次查询都要记录当前第几次尝试 -- 若 SFTP 连接失败,应区分“连接失败”和“文件不存在” -- 对于连接失败、认证失败、目录错误,不按“文件不存在超时放行”处理,而按系统异常处理 -- 若目录配置错误,应作为配置异常处理 +- 每次查询都要记录当前第几次尝试。 +- 若 SFTP 连接失败,应区分“连接失败”和“文件不存在”。 +- 连接失败、认证失败、目录错误不按“文件不存在超时 NG 放行”处理,而按系统异常处理。 +- 若目录配置错误,应作为配置异常处理。 --- -## 11. 安灯 HTTP 接口设计建议 +## 13. 安灯 HTTP 接口设计 -### 11.1 报警触发场景 +### 13.1 报警触发场景 -当前版本建议至少在以下场景调用安灯接口: +当前版本至少在以下场景调用安灯接口: -- 扫码连续失败 3 次 +- 扫码枪1和扫码枪2均扫码失败 后续可扩展场景: - SFTP 文件未找到超时 - PLC 通信异常 -- 串口设备离线 +- 扫码枪设备离线 -### 11.2 请求内容建议 +### 13.2 请求内容建议 建议请求体包含: @@ -616,50 +700,53 @@ - 报警类型 - 报警编码 - 报警描述 +- 扫码枪1失败原因 +- 扫码枪2失败原因 - 二维码内容(若有) - 触发时间 - 设备名/IP -### 11.3 报警代码建议 +### 13.3 报警代码建议 | 代码 | 含义 | | --- | --- | -| 1001 | 扫码连续失败 3 次 | +| 1001 | 两把扫码枪均扫码失败 | | 1002 | SFTP 文件超时未找到 | | 1003 | SFTP 连接异常 | -| 1004 | 串口设备异常 | +| 1004 | 扫码枪设备异常 | | 1005 | PLC 通信异常 | -### 11.4 设计要求 +### 13.4 设计要求 -- HTTP 请求必须有超时控制 -- 请求失败必须记录日志 -- 若报警接口失败,不影响当前“放行”主流程,但需在日志和结果码中体现 +- HTTP 请求必须有超时控制。 +- 请求失败必须记录日志。 +- 若报警接口失败,不影响当前“NG 放行”主流程,但需在日志和结果记录中体现。 --- -## 12. 配置设计建议 +## 14. 配置设计建议 建议在配置文件中划分如下配置节: -### 12.1 PlcOptions +### 14.1 PlcOptions -- IP 地址 -- 端口 +- PLC IP 地址 +- PLC 端口 +- FINS 网络号、节点号、单元号 - 轮询周期 - 连接超时 - 点位地址映射 - 放行脉冲时长 - 放行应答超时时间 -### 12.2 ScannerOptions +### 14.2 ScannerOptions -- 串口参数 -- 触发命令 -- 单次扫码超时 -- 最大扫码次数(当前固定建议值为 3) +- 扫码枪1 IP、端口、超时、触发命令 +- 扫码枪2 IP、端口、超时、触发命令 +- 返回结束符或报文规则 +- 双扫码汇总策略 -### 12.3 SftpOptions +### 14.3 SftpOptions - 服务器连接参数 - 根目录 @@ -667,7 +754,7 @@ - 重试等待秒数 - 最大重试次数 -### 12.4 AndonOptions +### 14.4 AndonOptions - 是否启用 - 接口地址 @@ -676,31 +763,33 @@ - 工位编码 - 默认报警码映射 -### 12.5 WorkflowOptions +### 14.5 WorkflowOptions -- 启动前是否要求 `PlcReady = 1` -- 启动前是否要求自动模式 - 故障恢复后是否必须人工复位 - 日志保留天数 +- 放行确认是否启用 +- 两把扫码枪是否并发触发 说明: -- 当前版本“扫码失败 3 次后放行”和“文件未找到超时后放行”属于**固定业务规则**,不作为普通配置开关 -- 若未来需要改成拦截模式,应通过需求变更单独评审 +- 当前版本“两把扫码枪均失败后 NG 放行”和“文件未找到超时后 NG 放行”属于固定业务规则,不作为普通配置开关。 +- 若未来需要改成拦截模式,应通过需求变更单独评审。 --- -## 13. UI 展示建议 +## 15. UI 展示建议 当前系统虽为简化版,仍建议主界面至少展示以下信息: - PLC 连接状态 -- 扫码枪连接状态 +- 扫码枪1连接状态 +- 扫码枪2连接状态 - SFTP 连接状态 - 安灯接口状态 - 当前流程状态 -- 当前二维码内容 -- 扫码次数 +- 扫码枪1原始结果 +- 扫码枪2原始结果 +- 最终选定条码 - SFTP 查询次数 - 当前结果描述 - 最近若干条运行日志 @@ -709,69 +798,71 @@ - 手动复位 - 手动重连 PLC -- 手动重连扫码枪 +- 手动重连扫码枪1 +- 手动重连扫码枪2 - 测试安灯接口 - 打开配置 --- -## 14. 异常处理策略 +## 16. 异常处理策略 -### 14.1 业务异常 +### 16.1 业务异常 业务异常指当前板流程内的可接受结果: -- 扫码失败 3 次 +- 两把扫码枪均扫码失败 - 文件未找到超时 处理原则: -- 记录日志 -- 写入明确结果码 -- 当前版本仍放行 +- 记录日志。 +- 写入明确结果码 `20`。 +- 当前版本仍放行。 -### 14.2 系统异常 +### 16.2 系统异常 系统异常指上位机自身能力不可用: - PLC 无法连接 -- 串口打开失败 +- 两把扫码枪 TCP 连接均不可用且被判定为设备异常 - SFTP 配置缺失或无法认证 - SFTP 根目录不存在 - 安灯接口配置错误 处理原则: -- 置 `SystemFault` -- 写入故障结果码 -- 日志详细记录异常上下文 -- 进入 `Faulted` -- 故障恢复后需人工复位或 PLC 复位后方可重新接板 +- 写入故障结果码 `90`。 +- 日志详细记录异常上下文。 +- 进入 `Faulted`。 +- 故障恢复后需人工复位或 PLC 复位后方可重新接板。 -### 14.3 防重入要求 +### 16.3 防重入要求 -- 流程执行中必须禁止重复触发新板流程 -- 放行写入必须有一次性保护 -- SFTP 查询不得并发重复执行 -- 报警接口同一事件只发送一次 +- 流程执行中必须禁止重复触发新板流程。 +- 放行写入必须有一次性保护。 +- SFTP 查询不得并发重复执行。 +- 报警接口同一事件只发送一次。 +- PLC 复位前不得自动清除最终结果码。 --- -## 15. 日志与追溯要求 +## 17. 日志与追溯要求 -### 15.1 日志级别建议 +### 17.1 日志级别建议 -- `Info`:流程开始、扫码结果、SFTP命中、放行完成 -- `Warning`:扫码重试、文件未找到等待重试、报警接口失败、放行应答超时 -- `Error`:PLC/串口/SFTP/HTTP 调用异常、配置异常 +- `Info`:流程开始、扫码成功、SFTP 命中、放行完成、PLC 复位完成。 +- `Warning`:单把扫码枪失败、两把扫码枪条码不一致、文件未找到等待重试、报警接口失败、放行应答超时。 +- `Error`:PLC/扫码枪/SFTP/HTTP 调用异常、配置异常。 -### 15.2 单板结果摘要建议 +### 17.2 单板结果摘要建议 每块 PCB 建议形成一条结果记录,包含: - 流水时间戳 -- 条码 -- 扫码总次数 +- 扫码枪1原始条码 +- 扫码枪2原始条码 +- 最终选定条码 - SFTP 查询次数 - 最终结果码 - 最终结果描述 @@ -781,72 +872,81 @@ --- -## 16. 联调与验收建议 +## 18. 联调与验收建议 -### 16.1 联调顺序建议 +### 18.1 联调顺序建议 -1. 先打通 PLC 基础读写 -2. 再联调扫码枪串口触发 -3. 再联调 SFTP 文件查找 -4. 再联调安灯 HTTP 接口 -5. 最后做整流程串联测试 +1. 先打通 PLC FINS 基础读写。 +2. 再联调扫码枪1 TCP 触发。 +3. 再联调扫码枪2 TCP 触发。 +4. 再联调双扫码结果汇总。 +5. 再联调 SFTP 文件查找。 +6. 再联调安灯 HTTP 接口。 +7. 最后做整流程串联测试。 -### 16.2 关键测试场景 +### 18.2 关键测试场景 -1. PLC 到位,扫码一次成功,SFTP 一次命中,正常放行 -2. PLC 到位,扫码前两次失败,第三次成功,SFTP 命中,正常放行 -3. PLC 到位,扫码三次失败,安灯报警成功,最终放行 -4. PLC 到位,扫码成功,SFTP 多次未命中,超时后放行 -5. PLC 到位,扫码成功,但 SFTP 连接异常,系统进入故障,禁止自动放行 -6. 安灯接口调用失败,但扫码失败场景仍按既定规则放行,并有异常日志 -7. 流程处理中重复收到到位信号,不允许重复触发 -8. PLC 复位后,上位机状态位正确清除 -9. PLC 未进入自动模式或未给 `PlcReady` 时,上位机不接板 +1. PLC 到位,扫码枪1成功,扫码枪2成功且条码一致,SFTP 一次命中,正常放行。 +2. PLC 到位,仅扫码枪1成功,SFTP 命中,正常放行。 +3. PLC 到位,仅扫码枪2成功,SFTP 命中,正常放行。 +4. PLC 到位,两把扫码枪都成功但条码不一致,优先使用扫码枪1条码,SFTP 命中,正常放行并记录不一致日志。 +5. PLC 到位,两把扫码枪均失败,安灯报警成功,最终 NG 放行。 +6. PLC 到位,扫码成功,SFTP 多次未命中,超时后 NG 放行。 +7. PLC 到位,扫码成功,但 SFTP 连接异常,系统进入故障,禁止自动放行。 +8. 安灯接口调用失败,但扫码失败场景仍按既定规则 NG 放行,并有异常日志。 +9. 流程处理中重复收到到位信号,不允许重复触发。 +10. PLC 写入 `D1002 PlcReset = 1` 后,上位机正确清除 `D1051`、`D1052`、`D1100`。 -### 16.3 验收重点 +### 18.3 验收重点 -- 是否稳定识别到位触发 -- 是否正确限制扫码 3 次总尝试 -- 是否按配置进行 SFTP 首次查询和后续 N 次重试 -- 是否在规定场景下正确放行 -- 是否正确触发安灯报警 -- 是否正确区分“文件未找到”和“SFTP 系统异常” -- 是否具备清晰日志和结果码追溯能力 +- 是否稳定识别 `D1001 PcbArrived` 到位触发。 +- 是否正确写入 `D1051 PcBusy`。 +- 是否正确触发两把 TCP 扫码枪并发送 `T`。 +- 是否按“任一成功即可”规则进入 SFTP。 +- 是否在双成功不一致时优先使用扫码枪1。 +- 是否按配置进行 SFTP 首次查询和后续 N 次重试。 +- 是否在规定场景下正确写入 `D1100 ResultCode`。 +- 是否正确输出 `D1052 ReleasePermit`。 +- 是否正确处理 `D1003 PlcAckRelease` 和 `D1002 PlcReset`。 +- 是否正确触发安灯报警。 +- 是否正确区分“文件未找到”和“SFTP 系统异常”。 +- 是否具备清晰日志和结果码追溯能力。 --- -## 17. 后续扩展建议 +## 19. 与旧设计差异摘要 -当前设计已为以下扩展预留空间: +### 19.1 已调整内容 -- 将扫码失败放行改为配置策略 -- 将文件未找到是否报警改为配置策略 -- 增加工位编号和多站点配置 -- 增加结果明细落库 -- 增加人工复位与权限控制 -- 增加多工位或多缓存位流程 -- 增加 MES 校验或工单绑定 +- PLC 通信由旧设计中的 Modbus TCP 调整为 OMRON FINS。 +- PLC 地址由旧设计中的线圈、离散输入、保持寄存器表达,调整为 `D1001`、`D1051`、`D1100` 系列点位。 +- 扫码枪由旧设计中的单把串口扫码枪,调整为两把 TCP Socket 扫码枪。 +- 扫码触发命令由可配置串口触发命令,调整为当前流程固定命令 `T`。 +- 扫码成功规则由单扫码结果,调整为双扫码汇总后“任一成功即可”。 +- 双扫码都成功但内容不一致时,采用“优先扫码枪1并记录日志”的规则。 +- 最终 PLC 结果码收敛为 `0`、`1`、`10`、`20`、`90`。 + +### 19.2 继续保留内容 + +- SFTP 文件校验仍保留在扫码成功之后、放行之前。 +- 安灯报警仍保留在扫码失败位置。 +- 文件未找到超时仍按业务 NG 放行。 +- SFTP 连接、认证、目录等系统异常仍进入系统故障。 +- 安灯接口失败不阻断既定放行流程。 +- 关键日志、单板结果追溯和异常分级处理继续保留。 --- -## 18. 最终结论 +## 20. 最终结论 -当前项目适合采用**单工位串行状态机 + Modbus TCP + 串口扫码 + SFTP 文件校验 + HTTP 安灯报警**的轻量化上位机方案。 +当前项目适合采用**单工位串行状态机 + OMRON FINS + 双 TCP 扫码枪 + SFTP 文件校验 + HTTP 安灯报警**的轻量化上位机方案。 该方案具备以下特点: -- 与当前需求完全匹配 -- 软件结构简单清晰 -- PLC 点位数量少,易联调 -- 能满足“扫码失败放行”和“文件未找到超时放行”的业务要求 -- 对 SFTP 系统异常与普通文件未命中进行了明确区分 -- 具备后续扩展为更完整目检系统的演进空间 - -建议后续开发时优先落地: - -1. 配置模型 -2. PLC 点位通信服务 -3. 扫码服务 -4. SFTP 校验服务 -5. 主流程状态机 -6. 主界面状态展示 +- 与当前定稿通信流程匹配。 +- PLC 点位少,易联调。 +- 双扫码枪提高扫码成功率。 +- SFTP 与安灯继续保持原业务位置,不丢失既有流程约束。 +- 能满足“扫码失败 NG 放行”和“文件未找到超时 NG 放行”的业务要求。 +- 对 SFTP 系统异常与普通文件未命中进行了明确区分。 +- 具备后续扩展为更完整目检系统的演进空间。 diff --git a/docs/2026-05-17-pcb-check-final-workflow.md b/docs/2026-05-17-pcb-check-final-workflow.md new file mode 100644 index 0000000..1962ade --- /dev/null +++ b/docs/2026-05-17-pcb-check-final-workflow.md @@ -0,0 +1,132 @@ +# PCB 目检放行软件最终工作流程图 + +## 1. 通信结构 + +```text +PLC <--OMRON FINS--> C# 上位机 <--TCP Socket--> 扫码枪1 + <--TCP Socket--> 扫码枪2 + <--SFTP----------> SFTP 服务器 + <--HTTP----------> 安灯系统 +``` + +## 2. 主流程图 + +```mermaid +flowchart TD + A[PLC: D1001 PcbArrived=1] --> B[上位机通过 FINS 读取到位信号] + B --> C{当前是否空闲且未故障} + + C -->|否| C1[忽略本次触发并继续监视] + C -->|是| D[写入 D1051 PcBusy=1] + D --> E[写入 D1100 ResultCode=1 处理中] + E --> F[触发扫码枪1: TCP 发送 T] + E --> G[触发扫码枪2: TCP 发送 T] + + F --> H[接收扫码枪1结果] + G --> I[接收扫码枪2结果] + H --> J[汇总双扫码结果] + I --> J + + J --> K{是否至少一路扫码成功} + K -->|否| L[调用安灯: 两把扫码枪均失败] + L --> M[记录报警结果] + M --> N[写入 D1100 ResultCode=20 NG放行] + + K -->|是| O{两路条码是否都成功且不一致} + O -->|是| P[优先使用扫码枪1条码并记录不一致日志] + O -->|否| Q[使用成功条码] + P --> R[SFTP 查找最终条码对应文件] + Q --> R + + R --> S{SFTP 校验结果} + S -->|文件存在| T[写入 D1100 ResultCode=10 正常放行] + S -->|文件未找到且未到重试上限| U[等待配置间隔后重试] + U --> R + S -->|文件未找到达到上限| V[写入 D1100 ResultCode=20 NG放行] + S -->|连接/认证/目录异常| W[写入 D1100 ResultCode=90 系统故障] + + T --> X[写入 D1052 ReleasePermit=1] + V --> X + N --> X + + X --> Y{是否启用 D1003 PlcAckRelease} + Y -->|启用| Z[等待 PLC 写入 D1003=1] + Y -->|不启用| AA[按放行脉冲时间保持] + Z --> AB[等待 PLC 复位] + AA --> AB + + AB --> AC[PLC: D1002 PlcReset=1] + AC --> AD[上位机执行复位] + AD --> AE[写入 D1051=0] + AE --> AF[写入 D1052=0] + AF --> AG[写入 D1100=0 Idle] + AG --> AH[回到空闲监视] + + W --> AI[进入故障锁存] + AI --> AJ[等待人工/PLC复位] + AJ --> AC +``` + +## 3. 双扫码判定 + +```mermaid +flowchart TD + A[扫码枪1结果 + 扫码枪2结果] --> B{扫码枪1成功?} + A --> C{扫码枪2成功?} + + B -->|是| D{扫码枪2也成功?} + B -->|否| E{扫码枪2成功?} + + D -->|否| F[使用扫码枪1条码] + D -->|是| G{两路条码一致?} + G -->|是| H[使用共同条码] + G -->|否| I[使用扫码枪1条码并记录不一致] + + E -->|是| J[使用扫码枪2条码] + E -->|否| K[双扫码失败] + + F --> L[进入 SFTP 校验] + H --> L + I --> L + J --> L + K --> M[安灯报警 + NG放行] +``` + +## 4. PLC 点位与结果码 + +### 4.1 PLC -> 上位机 + +| 地址 | 名称 | 含义 | +| --- | --- | --- | +| D1001 | PcbArrived | PCB 到位,请求处理 | +| D1002 | PlcReset | PLC 请求上位机清状态/复位 | +| D1003 | PlcAckRelease | PLC 已收到放行信号,可选 | + +### 4.2 上位机 -> PLC + +| 地址 | 名称 | 含义 | +| --- | --- | --- | +| D1051 | PcBusy | 上位机正在处理当前 PCB | +| D1052 | ReleasePermit | 放行脉冲信号 | +| D1100 | ResultCode | 本次最终结果代码 | + +### 4.3 ResultCode + +| 数值 | 含义 | 是否放行 | +| --- | --- | --- | +| 0 | Idle / 无结果 | 否 | +| 1 | 处理中 | 否 | +| 10 | 正常放行 | 是 | +| 20 | NG 放行 | 是 | +| 90 | 系统故障 | 否 | + +## 5. 关键规则 + +- `D1001` 只作为新板触发信号;流程执行中重复到位不得重入。 +- 双扫码采用“任一成功即可”。 +- 两把扫码枪都成功但条码不一致时,优先使用扫码枪1条码,并记录扫码枪2条码。 +- SFTP 文件校验位于扫码成功之后、PLC 放行之前。 +- SFTP 文件未找到达到重试上限后,写入 `ResultCode=20` 并放行。 +- SFTP 连接失败、认证失败、根目录不存在等系统异常写入 `ResultCode=90`,不自动放行。 +- 两把扫码枪均失败时调用安灯报警;安灯接口失败不阻断 `ResultCode=20` 的 NG 放行。 +- 放行完成后等待 PLC 复位信号 `D1002`,再清 `D1051`、`D1052`、`D1100`。 diff --git a/docs/superpowers/plans/2026-04-19-csv-record-persistence.md b/docs/superpowers/plans/2026-04-19-csv-record-persistence.md new file mode 100644 index 0000000..c5d51c9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-csv-record-persistence.md @@ -0,0 +1,406 @@ +# CSV Record Persistence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add CSV-only daily record persistence, startup recovery from today's CSV, current-day statistics, and a UI list limited to the most recent 100 records without letting CSV failures break the production workflow. + +**Architecture:** Introduce a dedicated CSV repository as the single source of truth for board history. Keep `WorkflowHostedService` responsible for producing `BoardProcessRecord`, let the repository own persistence and daily statistics, and let `IAppStateStore`/`MainWindowViewModel` project repository-backed data to the dashboard. + +**Tech Stack:** .NET 8, WPF, xUnit, existing `BackgroundService`/DI patterns, CSV file I/O via `System.IO` + +--- + +### Task 1: Add repository-facing models and state store hooks + +**Files:** +- Modify: `src/AxiOmron.PcbCheck/Models/WorkflowModels.cs` +- Modify: `src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs` +- Modify: `src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs` +- Modify: `src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs` +- Modify: `src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs` +- Test: `tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs` + +- [ ] **Step 1: Write the failing ViewModel/state test** + +```csharp +[Fact] +public void Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount() +{ + var stateStore = new AppStateStore(); + stateStore.UpdateSnapshot(snapshot => + { + snapshot.RecordStatisticDate = new DateOnly(2026, 4, 19); + snapshot.TodayProcessCount = 150; + snapshot.TodayOkCount = 120; + snapshot.TodayNgCount = 30; + }); + + var viewModel = CreateViewModel(stateStore); + + Assert.Equal(150, viewModel.TodayProcessCount); + Assert.Equal(120, viewModel.TodayOkCount); + Assert.Equal(30, viewModel.TodayNgCount); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests.Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount` + +Expected: FAIL because `RuntimeSnapshot`/`MainWindowViewModel` do not yet expose repository-backed record statistics. + +- [ ] **Step 3: Add minimal shared models and interface hooks** + +```csharp +public sealed class BoardRecordStatistics +{ + public DateOnly StatisticDate { get; set; } + public int TotalCount { get; set; } + public int OkCount { get; set; } + public int NgCount { get; set; } + public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now; +} + +public interface IAppStateStore +{ + void ReplaceRecords(IReadOnlyList records, BoardRecordStatistics statistics); + void UpdateRecordStatistics(BoardRecordStatistics statistics); +} +``` + +- [ ] **Step 4: Extend runtime snapshot and state store implementations** + +```csharp +public sealed class RuntimeSnapshot +{ + public IList BoardRecords { get; } = new List(); + public DateOnly? RecordStatisticDate { get; set; } + public int TodayProcessCount { get; set; } + public int TodayOkCount { get; set; } + public int TodayNgCount { get; set; } +} + +public void ReplaceRecords(IReadOnlyList records, BoardRecordStatistics statistics) +{ + UpdateSnapshot(snapshot => + { + snapshot.BoardRecords.Clear(); + foreach (BoardProcessRecord record in records) + { + snapshot.BoardRecords.Add(record); + } + + ApplyStatistics(snapshot, statistics); + }); +} +``` + +- [ ] **Step 5: Run the targeted test to verify it passes** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests.Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount` + +Expected: PASS + +### Task 2: Build the CSV repository with daily files, recovery, and bad-line tolerance + +**Files:** +- Modify: `src/AxiOmron.PcbCheck/Options/AppConfig.cs` +- Modify: `src/AxiOmron.PcbCheck/appConfig.json` +- Modify: `src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs` +- Create: `src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs` +- Test: `tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs` + +- [ ] **Step 1: Write the failing repository tests** + +```csharp +[Fact] +public async Task AppendAsync_ShouldCreateDailyCsv_AndLoadTodayAsync_ShouldRecoverStatistics() +{ + var repository = CreateRepository("2026-04-19"); + await repository.AppendAsync(CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.Zero), (ushort)WorkflowResultCode.Passed), CancellationToken.None); + await repository.AppendAsync(CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.Zero), (ushort)WorkflowResultCode.NgReleased), CancellationToken.None); + + BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None); + + Assert.Equal(2, result.Records.Count); + Assert.Equal(2, result.Statistics.TotalCount); + Assert.Equal(1, result.Statistics.OkCount); + Assert.Equal(1, result.Statistics.NgCount); +} + +[Fact] +public async Task LoadCurrentDayAsync_ShouldSkipBrokenRows_AndContinue() +{ + File.WriteAllText(_csvPath, "StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary\r\nbroken,row\r\n2026-04-19 08:00:00.000,2026-04-19 08:01:00.000,PCB-001,1,1,10,OK,True,False,\r\n"); + + BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None); + + Assert.Single(result.Records); + Assert.Equal("PCB-001", result.Records[0].Barcode); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter CsvBoardRecordRepositoryTests` + +Expected: FAIL because the repository and related types do not exist yet. + +- [ ] **Step 3: Add configuration and repository contract** + +```csharp +public sealed class RecordPersistenceOptions +{ + public string DirectoryPath { get; set; } = "Data\\Records"; + public int MaxRecentRecordCount { get; set; } = 100; + public int DayChangeCheckIntervalSeconds { get; set; } = 30; +} + +public interface IBoardRecordRepository +{ + Task LoadCurrentDayAsync(CancellationToken cancellationToken); + Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken); +} +``` + +- [ ] **Step 4: Write minimal CSV repository implementation** + +```csharp +public sealed class CsvBoardRecordRepository : IBoardRecordRepository +{ + public async Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken) + { + string path = GetDailyFilePath(DateOnly.FromDateTime(record.CompletedAt.LocalDateTime.Date)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + bool writeHeader = !File.Exists(path); + await using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + await using var writer = new StreamWriter(stream, new UTF8Encoding(true)); + if (writeHeader) + { + await writer.WriteLineAsync(HeaderLine); + } + + await writer.WriteLineAsync(ToCsvLine(record)); + return await LoadCurrentDayAsync(cancellationToken); + } +} +``` + +- [ ] **Step 5: Run repository tests to verify they pass** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter CsvBoardRecordRepositoryTests` + +Expected: PASS + +### Task 3: Integrate repository with startup recovery and workflow completion + +**Files:** +- Modify: `src/AxiOmron.PcbCheck/App.xaml.cs` +- Modify: `src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs` +- Test: `tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs` + +- [ ] **Step 1: Write the failing workflow integration tests** + +```csharp +[Fact] +public async Task StartAsync_ShouldRestoreTodayRecords_FromRepository() +{ + var stateStore = new AppStateStore(); + var repository = new FakeBoardRecordRepository + { + LoadResult = new BoardRecordLoadResult + { + Records = new List { CreateRecord("PCB-RESTORED", DateTimeOffset.Now, (ushort)WorkflowResultCode.Passed) }, + Statistics = new BoardRecordStatistics { StatisticDate = DateOnly.FromDateTime(DateTime.Today), TotalCount = 1, OkCount = 1, NgCount = 0 } + } + }; + + var service = CreateWorkflowService(stateStore, repository); + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + RuntimeSnapshot snapshot = stateStore.GetSnapshot(); + Assert.Single(snapshot.BoardRecords); + Assert.Equal(1, snapshot.TodayProcessCount); +} + +[Fact] +public async Task RunWorkflowOnce_ShouldNotFault_WhenRepositoryAppendFails() +{ + var repository = new FakeBoardRecordRepository { AppendException = new IOException("disk full") }; + var service = CreateSuccessfulWorkflowService(repository, stateStore); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + Assert.NotEqual(WorkflowState.Faulted, stateStore.GetSnapshot().WorkflowState); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter WorkflowHostedServiceTests` + +Expected: FAIL because `WorkflowHostedService` does not use the repository yet. + +- [ ] **Step 3: Wire startup recovery into the hosted service** + +```csharp +protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + await RestoreRecordsAsync(stoppingToken).ConfigureAwait(false); + await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false); + await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false); + PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位"); + // existing polling loop +} +``` + +- [ ] **Step 4: Persist records on completion with fault isolation** + +```csharp +try +{ + BoardRecordAppendResult appendResult = await _boardRecordRepository.AppendAsync(record, cancellationToken).ConfigureAwait(false); + _stateStore.ReplaceRecords(appendResult.RecentRecords, appendResult.Statistics); +} +catch (Exception ex) +{ + _appLogger.LogError(ex, "处理记录写入 CSV 失败"); + UpdateSnapshot(snapshot => snapshot.ResultDescription = "处理完成,但记录持久化失败"); +} +``` + +- [ ] **Step 5: Run workflow tests to verify they pass** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter WorkflowHostedServiceTests` + +Expected: PASS + +### Task 4: Update the dashboard ViewModel and UI bindings for repository-backed history + +**Files:** +- Modify: `src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs` +- Modify: `src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml` +- Test: `tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs` + +- [ ] **Step 1: Write the failing ViewModel list-limit and date-display tests** + +```csharp +[Fact] +public void Constructor_ShouldLoadRecentRecords_FromSnapshot_AndExposeStatisticDate() +{ + var stateStore = new AppStateStore(); + stateStore.ReplaceRecords(CreateRecords(120), new BoardRecordStatistics + { + StatisticDate = new DateOnly(2026, 4, 19), + TotalCount = 120, + OkCount = 100, + NgCount = 20 + }); + + var viewModel = CreateViewModel(stateStore); + + Assert.Equal(100, viewModel.RecentProcessRecords.Count); + Assert.Equal("2026-04-19", viewModel.RecordStatisticDateText); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests` + +Expected: FAIL because the ViewModel does not load records/statistic date from the snapshot yet. + +- [ ] **Step 3: Update the ViewModel to hydrate from snapshot-backed records and statistics** + +```csharp +private void ApplySnapshot(RuntimeSnapshot snapshot) +{ + RecordStatisticDateText = snapshot.RecordStatisticDate?.ToString("yyyy-MM-dd") ?? "-"; + TodayProcessCount = snapshot.TodayProcessCount; + TodayOkCount = snapshot.TodayOkCount; + TodayNgCount = snapshot.TodayNgCount; + + RecentBoards.Clear(); + foreach (BoardProcessRecord record in snapshot.BoardRecords.OrderByDescending(item => item.CompletedAt).Take(_workflowOptions.MaxBoardRecords)) + { + RecentBoards.Add(record); + } +} +``` + +- [ ] **Step 4: Surface the current statistic date in the dashboard** + +```xml + + + + +``` + +- [ ] **Step 5: Run ViewModel tests to verify they pass** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests` + +Expected: PASS + +### Task 5: Add cross-day refresh and run end-to-end verification + +**Files:** +- Modify: `src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs` +- Modify: `src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs` +- Modify: `src/AxiOmron.PcbCheck/App.xaml.cs` +- Test: `tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs` +- Test: `tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs` + +- [ ] **Step 1: Write the failing cross-day tests** + +```csharp +[Fact] +public async Task LoadCurrentDayAsync_ShouldSwitchToNewDay_WhenClockMovesPastMidnight() +{ + var clock = new FakeClock(new DateTimeOffset(2026, 4, 19, 23, 59, 50, TimeSpan.Zero)); + var repository = CreateRepository(clock); + await repository.AppendAsync(CreateRecord("PCB-OLD", clock.Now.AddSeconds(5), (ushort)WorkflowResultCode.Passed), CancellationToken.None); + + clock.Set(new DateTimeOffset(2026, 4, 20, 0, 0, 10, TimeSpan.Zero)); + + BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None); + Assert.Empty(result.Records); + Assert.Equal(new DateOnly(2026, 4, 20), result.Statistics.StatisticDate); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter FullyQualifiedName~CsvBoardRecordRepositoryTests|FullyQualifiedName~WorkflowHostedServiceTests` + +Expected: FAIL because day switching is not implemented yet. + +- [ ] **Step 3: Implement day-change checks and repository refresh** + +```csharp +private async Task RestoreRecordsAsync(CancellationToken cancellationToken) +{ + BoardRecordLoadResult loadResult = await _boardRecordRepository.LoadCurrentDayAsync(cancellationToken).ConfigureAwait(false); + _stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics); +} +``` + +- [ ] **Step 4: Register the repository in DI and keep the current workspace cleanly wired** + +```csharp +services.AddSingleton(); +``` + +- [ ] **Step 5: Run the full test suite and build** + +Run: `dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj` + +Expected: PASS + +Run: `dotnet build src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj -c Debug` + +Expected: Build succeeded, 0 errors diff --git a/docs/superpowers/specs/2026-04-19-csv-record-persistence-design.md b/docs/superpowers/specs/2026-04-19-csv-record-persistence-design.md new file mode 100644 index 0000000..8eceb20 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-csv-record-persistence-design.md @@ -0,0 +1,378 @@ +# Axi Omron PCB Check CSV 记录持久化设计 + +## 1. 设计目标 + +本设计用于为 `AxiOmron.PcbCheck` 增加“扫码任务与处理结果记录持久化”能力,并满足以下目标: + +- 所有处理记录仅使用 CSV 持久化,不引入数据库。 +- CSV 按天分文件,文件名固定为 `yyyy-MM-dd.csv`。 +- 程序启动时自动从“当天 CSV”回读恢复首页记录与统计数据。 +- 首页列表只显示最近 100 条记录。 +- 统计区域在每次任务完成后实时更新,且统计口径基于“当天全部有效记录”。 +- CSV 读写异常不能影响主流程,不允许因为持久化失败而阻断扫码、SFTP、PLC 放行等核心业务。 + +## 2. 当前状态 + +当前系统在 `WorkflowHostedService` 完成单板流程后,会创建 `BoardProcessRecord` 并通过 `IAppStateStore.RecordAdded` 推送到界面。 + +当前实现存在以下限制: + +- 记录仅保存在内存中,程序重启后丢失。 +- 首页统计值通过 `RecentBoards` 重新计算,而 `RecentBoards` 仅保留 UI 最近记录,统计口径不等于“当天总量”。 +- 跨 0 点后没有自动切换到新一天数据视图。 +- 记录读写与统计未形成独立边界,后续扩展导出、恢复和容错能力较弱。 + +## 3. 设计结论 + +采用“独立记录仓储服务 + CSV 作为唯一事实源 + UI 只展示最近 100 条”的方案。 + +核心结论如下: + +- 新增记录仓储服务,负责 CSV 追加写入、当天 CSV 回读和当天统计维护。 +- `WorkflowHostedService` 仍负责生成 `BoardProcessRecord`,但不再自行承担记录持久化逻辑。 +- `MainWindowViewModel` 只负责展示最近 100 条和当前统计结果,不再以 UI 列表作为统计真值来源。 +- CSV 是唯一持久化介质;内存集合只是当前界面投影。 +- 任意 CSV 异常均只记日志、尽力恢复,不改变主流程状态机,不触发 `Faulted`。 + +## 4. 范围与非范围 + +### 4.1 本次范围 + +- 新增 CSV 记录仓储服务及其接口。 +- 启动时回读当天 CSV。 +- 首页最近处理记录列表限制为最近 100 条。 +- 首页统计区按当天全量有效记录实时计算。 +- 跨 0 点自动切换到新一天视图与统计。 +- 为 CSV 读写、坏行容错和跨天行为补充自动化测试。 + +### 4.2 非本次范围 + +- 不新增数据库、LiteDB、SQLite 或远程存储。 +- 不新增“查看历史任意日期记录”的完整追溯页面。 +- 不新增 CSV 手工编辑、补录、删除和回写功能。 +- 不改变现有主流程业务判定规则。 + +## 5. 总体架构 + +### 5.1 新增角色 + +新增接口与实现: + +- `IBoardRecordRepository` +- `CsvBoardRecordRepository` + +该服务职责明确且独立: + +1. 追加写入一条处理记录到目标日期 CSV。 +2. 启动时加载当天 CSV 的全部有效记录。 +3. 基于当天全部有效记录提供统计结果。 +4. 在检测到跨天时切换当前工作日并重新加载新一天数据。 + +### 5.2 与现有组件协作关系 + +- `WorkflowHostedService` + - 生成 `BoardProcessRecord` + - 调用仓储服务追加持久化 + - 在持久化成功或失败后继续通知 UI 更新 +- `IAppStateStore` + - 继续承担运行时 UI 事件分发 + - 新增“初始化记录”和“更新统计”的承载职责 + - 不再作为记录真值来源 +- `MainWindowViewModel` + - 绑定最近 100 条记录 + - 绑定今日统计 + - 不再从 `RecentBoards` 推导当天真实总量 + +## 6. 数据模型设计 + +### 6.1 沿用的记录模型 + +沿用现有 `BoardProcessRecord` 作为单板结果对象,字段如下: + +- `StartedAt` +- `CompletedAt` +- `Barcode` +- `ScanTryCount` +- `SftpTryCount` +- `ResultCode` +- `ResultDescription` +- `ReleaseSent` +- `AlarmRaised` +- `ExceptionSummary` + +### 6.2 新增统计模型 + +新增轻量统计对象,例如 `BoardRecordStatistics`,用于承载当前显示日的聚合结果: + +- `StatisticDate` +- `TotalCount` +- `OkCount` +- `NgCount` +- `LastUpdatedAt` + +统计定义如下: + +- `ResultCode == (ushort)WorkflowResultCode.Passed` 计为 OK。 +- `ResultCode != Passed` 且 `ResultCode != Processing` 且 `ResultCode != None` 计为 NG。 +- 坏行、空行、解析失败行不参与统计。 + +## 7. CSV 设计 + +### 7.1 存储目录 + +CSV 文件统一存放于应用运行目录下: + +`Data\Records\` + +例如: + +`Data\Records\2026-04-19.csv` + +### 7.2 文件命名规则 + +- 每天一个文件。 +- 文件名固定为 `yyyy-MM-dd.csv`。 +- 日期以本地时间为准。 + +### 7.3 行归属规则 + +每条记录归属哪个文件,由 `CompletedAt` 的本地日期决定: + +- `CompletedAt = 2026-04-19 23:59:59` 写入 `2026-04-19.csv` +- `CompletedAt = 2026-04-20 00:00:01` 写入 `2026-04-20.csv` + +若一块板在前一天启动、后一天完成,则归属“完成当天”。 + +### 7.4 编码与表头 + +- 文件编码使用 `UTF-8 with BOM`。 +- 文件首行写入固定表头。 + +固定列顺序如下: + +`StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary` + +### 7.5 字段格式 + +- 时间字段格式:`yyyy-MM-dd HH:mm:ss.fff` +- 布尔字段输出为 `True` / `False` +- 数值字段使用十进制字符串 +- 文本字段按标准 CSV 规则转义: + - 包含逗号、双引号、换行时加双引号包裹 + - 内部双引号转义为 `""` + +## 8. 启动恢复流程 + +应用启动后,在主窗口显示前完成以下步骤: + +1. 计算当前本地日期。 +2. 定位当天 CSV 文件。 +3. 若文件不存在,则视为当天暂无记录。 +4. 若文件存在,则读取全部数据行并逐行解析。 +5. 跳过坏行,保留有效记录。 +6. 按 `CompletedAt` 升序恢复有效记录,再取最后 100 条,并在首页按完成时间倒序展示。 +7. 基于当天全部有效记录计算统计结果。 +8. 将“最近 100 条 + 当天统计”写入 `IAppStateStore`,供首页绑定。 + +若启动恢复失败: + +- 记录错误日志。 +- 首页记录区显示空列表。 +- 统计区显示 0。 +- 主流程继续启动,不阻断应用运行。 + +## 9. 运行时写入与实时更新流程 + +每次单板流程完成后执行以下步骤: + +1. `WorkflowHostedService` 构造 `BoardProcessRecord`。 +2. 调用 `IBoardRecordRepository.AppendAsync(record, cancellationToken)`。 +3. 仓储服务将记录追加到目标日期 CSV。 +4. 若追加成功: + - 更新仓储内部当天统计缓存。 + - 更新仓储内部最近记录缓存。 +5. 若追加成功,将最新记录与统计通过 `IAppStateStore` 推送给 UI。 +6. 若追加失败,则保留上一份“已成功持久化”的历史表与统计,不把失败记录混入历史区和统计区,但当前流程结果仍通过现有运行状态区正常显示。 + +运行时界面行为约束: + +- 首页表格永远只保留最近 100 条。 +- 首页统计区始终显示当前本地日期对应的全量统计结果。 +- 每次成功持久化的任务完成后统计立即刷新,不依赖手动点击“刷新”。 + +## 10. 跨 0 点处理流程 + +### 10.1 切天规则 + +系统以本地日期为准,在程序不重启的情况下,跨 0 点后自动切换到新一天视图。 + +### 10.2 切天行为 + +当检测到当前本地日期已变化时,仓储服务执行: + +1. 更新当前工作日。 +2. 加载新一天的 `yyyy-MM-dd.csv`。 +3. 若新一天文件不存在,则初始化为空列表和 0 统计。 +4. 将新一天数据重新推送到 `IAppStateStore`。 + +### 10.3 检查时机 + +至少在以下时机检查是否跨天: + +- 应用启动时 +- 每次追加记录前 +- 每次追加记录后 +- 应用内增加一个固定周期的轻量日切检查定时器,建议每 30 秒检查一次,避免长时间空闲时界面不切天 + +### 10.4 跨天中的在制任务 + +若一块板跨天完成: + +- 列表与统计归属其 `CompletedAt` 当天。 +- 例如 `23:59:50` 开始、`00:00:05` 完成,则写入次日 CSV,并计入次日统计。 + +## 11. 健壮性与故障隔离 + +### 11.1 总原则 + +CSV 是记录与追溯系统,不是生产流程控制系统。 + +因此: + +- CSV 读写失败不能阻断主流程。 +- CSV 异常不能触发流程 `Faulted`。 +- CSV 异常只能以日志和界面状态提示形式暴露。 + +### 11.2 启动读取容错 + +- 文件不存在:按空数据处理。 +- 文件为空:按空数据处理。 +- 表头不匹配:记录 error,放弃恢复该文件并返回空结果,不做复杂兼容解析。 +- 某一行解析失败:跳过该行,记录 warning,继续处理剩余行。 +- 文件占用、权限不足、目录不存在:记录 error,返回空结果。 + +### 11.3 运行写入容错 + +- 目录不存在时自动创建目录。 +- 单次追加失败: + - 记录 error + - 不影响本次单板主流程完成 + - 不进入 `Faulted` + - UI 可更新一条轻量状态提示,例如“今日记录写入失败,请检查磁盘或权限” + - 历史记录表与统计区维持在最近一次成功持久化后的状态,避免界面展示与 CSV 真值不一致 + +### 11.4 坏行处理策略 + +坏行定义包括但不限于: + +- 列数不匹配 +- 时间无法解析 +- 数值字段无法解析 +- 布尔字段无法解析 + +处理方式统一为: + +- 跳过坏行 +- 写 warning 日志 +- 继续处理后续行 +- 不影响其余记录恢复 + +## 12. 并发与线程安全 + +记录仓储服务需保证多线程安全。 + +设计约束如下: + +- 对 CSV 文件读写使用独立的串行锁,例如 `SemaphoreSlim`。 +- 同一时刻仅允许一个追加写入执行,避免并发写入造成文件交错。 +- 启动恢复与运行时追加不可并发破坏内部缓存。 +- 对当天缓存的最近记录和统计更新应保持原子性,避免 UI 看到一半旧数据、一半新数据。 + +## 13. UI 与状态存储改造 + +### 13.1 列表展示 + +首页“处理记录”区只展示最近 100 条。 + +展示内容保持现有交互风格,不新增复杂筛选器。本次可沿用现有列: + +- 完成时间 +- 条码 +- 结果 + +### 13.2 统计展示 + +首页统计区每次任务完成后都自动更新,展示: + +- 今日处理数 +- OK 数 +- NG 数 + +建议增加“当前统计日期”展示,例如: + +`当前统计日期:2026-04-19` + +这样在跨 0 点后,操作员能明确看到界面已切换到新一天。 + +### 13.3 AppStateStore 调整 + +`IAppStateStore` 需要具备以下能力之一: + +- 支持初始化一批历史记录和统计对象 +- 或支持仓储服务在启动恢复后逐条推送记录并单独更新统计 + +推荐增加明确方法,而不是让 `MainWindowViewModel` 自行去猜测“当前是恢复模式还是实时模式”。 + +## 14. 测试设计 + +### 14.1 仓储服务测试 + +至少覆盖以下场景: + +1. 首次写入时自动创建目录和文件,并写入表头。 +2. 同一天多条记录追加后,CSV 内容顺序正确。 +3. 启动回读时能正确恢复当天记录与统计。 +4. 坏行存在时,仅跳过坏行,不影响其他行恢复。 +5. 跨 0 点后切换到新一天文件,列表与统计归零或切换为新一天数据。 +6. 跨天完成记录写入完成日文件,而不是开始日文件。 + +### 14.2 流程服务测试 + +至少覆盖以下场景: + +1. 流程完成后会调用仓储服务进行持久化。 +2. 仓储写入失败时,主流程仍能完成且不进入故障态。 +3. 仓储写入成功后,`IAppStateStore` 会收到最新记录和统计。 + +### 14.3 ViewModel 测试 + +至少覆盖以下场景: + +1. 启动恢复后,首页只显示最近 100 条。 +2. 当天统计值来自全量记录,而不是仅来自最近 100 条。 +3. 跨天刷新后,列表和统计能切到新一天。 + +## 15. 实施顺序建议 + +建议按以下顺序实施: + +1. 新增 CSV 仓储接口、实现与统计模型。 +2. 为仓储补齐 CSV 读写和坏行容错测试。 +3. 将 `WorkflowHostedService` 接入仓储服务。 +4. 扩展 `IAppStateStore` 支持初始化历史记录与统计。 +5. 调整 `MainWindowViewModel`,改为展示仓储恢复结果和实时统计。 +6. 补充跨 0 点与异常隔离测试。 +7. 运行构建与测试验证。 + +## 16. 最终结论 + +本次改造采用“CSV 仓储服务下沉”的方式实现记录持久化,能够在不引入数据库的前提下满足以下诉求: + +- 所有处理记录按天持久化到 CSV。 +- 程序重启后自动从当天 CSV 恢复首页数据。 +- 首页仅显示最近 100 条记录。 +- 统计区按当天全量有效记录实时计算。 +- 跨 0 点自动切换到新一天。 +- CSV 异常与主流程完全隔离,不影响扫码、SFTP、PLC 放行等核心业务。 + +该方案边界清晰、可测试、可维护,并为后续增加追溯页面、日期切换和导出能力保留了扩展空间。 diff --git a/docs/正式的工作流程 b/docs/正式的工作流程 new file mode 100644 index 0000000..74cb274 --- /dev/null +++ b/docs/正式的工作流程 @@ -0,0 +1,149 @@ +# OMRON 目检放行软件通信流程(FINS + TCP扫码枪) + +## 1. 系统通信结构 + +```text +PLC <--FINS--> C# 上位机 <--TCP--> 扫码枪1 + <--TCP--> 扫码枪2 +``` + +* PLC 与上位机: + + * 通信协议:OMRON FINS + * 上位机读取 PLC 点位 + * 上位机写入 PLC 点位 + +* 上位机与扫码枪: + + * 通信协议:TCP Socket + * 发送触发命令:`T` + * 接收扫码结果字符串 + +--- + +# 2. PLC 通信点位定义 + +## 2.1 PLC -> 上位机(上位机读取) + +| 地址 | 名称 | 类型 | 说明 | +| ----- | ------------- | ---- | --------------- | +| D1001 | PcbArrived | Bool | PCB已到位,请求上位机处理 | +| D1002 | PlcReset | Bool | PLC请求上位机清状态/复位 | +| D1003 | PlcAckRelease | Bool | PLC已接收到放行信号(可选) | + +预留: + +| 地址范围 | 说明 | +| ------------- | ---------------- | +| D1004 ~ D1010 | PLC -> 上位机扩展信号预留 | + +--- + +## 2.2 上位机 -> PLC(上位机写入) + +| 地址 | 名称 | 类型 | 说明 | +| ----- | ------------- | ---- | ------------ | +| D1051 | PcBusy | Bool | 上位机正在处理当前PCB | +| D1052 | ReleasePermit | Bool | 放行脉冲信号 | + +--- + +## 2.3 结果寄存器(上位机写入) + +| 地址 | 名称 | 类型 | 说明 | +| ----- | ---------- | --- | -------- | +| D1100 | ResultCode | Int | 本次最终结果代码 | + +### ResultCode 定义 + +| 数值 | 含义 | +| -- | ---- | +| 0 | Idle | +| 1 | 处理中 | +| 10 | 正常放行 | +| 20 | NG放行 | +| 90 | 系统故障 | + +--- + +# 3. TCP 扫码枪通信 + +## 3.1 扫码枪1 + +### 发送 + +```text +T +``` + +### 接收 + +```text +二维码/条码字符串 +``` + +--- + +## 3.2 扫码枪2 + +### 发送 + +```text +T +``` + +### 接收 + +```text +二维码/条码字符串 +``` + +--- + +# 4. 工序流程图 + +```mermaid +flowchart TD + + A[PLC写入 D1001=PcbArrived=1] + --> B[C#上位机通过FINS读取到位信号] + + B --> C[上位机写入 D1051=PcBusy=1] + B --> D[上位机写入 D1100=1 处理中] + + D --> E[TCP发送 T 到扫码枪1] + D --> F[TCP发送 T 到扫码枪2] + + E --> G[扫码枪1返回扫码结果] + F --> H[扫码枪2返回扫码结果] + + G --> I[上位机汇总扫码结果] + H --> I + + I --> J{扫码结果判定} + + J -->|成功| K[写入 D1100=10 正常放行] + J -->|失败| L[写入 D1100=20 NG放行] + J -->|异常| M[写入 D1100=90 系统故障] + + K --> N[写入 D1052=ReleasePermit=1] + L --> N + + N --> O[PLC收到放行信号] + + O --> P{是否启用确认信号} + + P -->|启用| Q[PLC写入 D1003=PlcAckRelease=1] + P -->|不启用| R[进入复位流程] + + Q --> R + + R --> S[PLC写入 D1002=PlcReset=1] + + S --> T[上位机执行复位] + + T --> U[写入 D1051=0] + U --> V[写入 D1052=0] + V --> W[写入 D1100=0 Idle] + +``` diff --git a/src/AxiOmron.PcbCheck/App.xaml.cs b/src/AxiOmron.PcbCheck/App.xaml.cs index 900259f..e194dac 100644 --- a/src/AxiOmron.PcbCheck/App.xaml.cs +++ b/src/AxiOmron.PcbCheck/App.xaml.cs @@ -119,9 +119,11 @@ public partial class App : Application services.AddSingleton(TimeProvider.System); services.AddHttpClient(nameof(AndonService)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj index adaf74a..496e887 100644 --- a/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj +++ b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj @@ -16,7 +16,6 @@ - diff --git a/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs b/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs index 605dedb..9fc0dc6 100644 --- a/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs +++ b/src/AxiOmron.PcbCheck/Converters/BooleanToVisibilityConverter.cs @@ -1,67 +1,67 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace AxiOmron.PcbCheck.Converters; - -/// -/// 将布尔值转换为 ,支持通过 ConverterParameter 反转判定。 -/// -/// -/// 当 ConverterParameter 为 "Invert"、"Inverse" 或 "!" 时,布尔值的真假意义取反。 -/// -public sealed class BooleanToVisibilityConverter : IValueConverter -{ - /// - /// 将布尔值转换为可见性。 - /// - /// 源布尔值。 - /// 目标类型。 - /// 若为 "Invert"/"!" 则反转判定。 - /// 当前区域信息。 - /// 或 。 - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - bool flag = value is bool b && b; - if (IsInvert(parameter)) - { - flag = !flag; - } - return flag ? Visibility.Visible : Visibility.Collapsed; - } - - /// - /// 将可见性反向转换为布尔值。 - /// - /// 目标可见性。 - /// 目标类型。 - /// 若为 "Invert"/"!" 则反转判定。 - /// 当前区域信息。 - /// 布尔值。 - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - bool flag = value is Visibility v && v == Visibility.Visible; - if (IsInvert(parameter)) - { - flag = !flag; - } - return flag; - } - - /// - /// 判断参数是否要求反转判定。 - /// - /// 参数值。 - /// 是否反转。 - private static bool IsInvert(object? parameter) - { - if (parameter is null) - { - return false; - } - string token = parameter.ToString() ?? string.Empty; - return token.Equals("Invert", StringComparison.OrdinalIgnoreCase) - || token.Equals("Inverse", StringComparison.OrdinalIgnoreCase) - || token.Equals("!", StringComparison.Ordinal); - } -} +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace AxiOmron.PcbCheck.Converters; + +/// +/// 将布尔值转换为 ,支持通过 ConverterParameter 反转判定。 +/// +/// +/// 当 ConverterParameter 为 "Invert"、"Inverse" 或 "!" 时,布尔值的真假意义取反。 +/// +public sealed class BooleanToVisibilityConverter : IValueConverter +{ + /// + /// 将布尔值转换为可见性。 + /// + /// 源布尔值。 + /// 目标类型。 + /// 若为 "Invert"/"!" 则反转判定。 + /// 当前区域信息。 + /// 或 。 + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + bool flag = value is bool b && b; + if (IsInvert(parameter)) + { + flag = !flag; + } + return flag ? Visibility.Visible : Visibility.Collapsed; + } + + /// + /// 将可见性反向转换为布尔值。 + /// + /// 目标可见性。 + /// 目标类型。 + /// 若为 "Invert"/"!" 则反转判定。 + /// 当前区域信息。 + /// 布尔值。 + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + bool flag = value is Visibility v && v == Visibility.Visible; + if (IsInvert(parameter)) + { + flag = !flag; + } + return flag; + } + + /// + /// 判断参数是否要求反转判定。 + /// + /// 参数值。 + /// 是否反转。 + private static bool IsInvert(object? parameter) + { + if (parameter is null) + { + return false; + } + string token = parameter.ToString() ?? string.Empty; + return token.Equals("Invert", StringComparison.OrdinalIgnoreCase) + || token.Equals("Inverse", StringComparison.OrdinalIgnoreCase) + || token.Equals("!", StringComparison.Ordinal); + } +} diff --git a/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs index 51bdde0..30970a2 100644 --- a/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs +++ b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs @@ -1,54 +1,54 @@ -using System.Globalization; -using System.Windows.Data; -using AxiOmron.PcbCheck.Models; - -namespace AxiOmron.PcbCheck.Converters; - -/// -/// 将 (以 存储)映射为简短 Tag 文本。 -/// -/// -/// 映射规则:Passed → "OK";Processing → "处理中";None → "-";其余视为 "NG"。 -/// -public sealed class ResultCodeToTagTextConverter : IValueConverter -{ - /// - /// 将结果码转换为 Tag 文本。 - /// - /// 结果码值。 - /// 目标类型。 - /// 扩展参数。 - /// 当前区域信息。 - /// 简短标签文本。 - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - ushort code = value switch - { - ushort u => u, - int i => (ushort)i, - WorkflowResultCode rc => (ushort)rc, - _ => (ushort)0 - }; - - return code switch - { - (ushort)WorkflowResultCode.Passed => "OK", - (ushort)WorkflowResultCode.Processing => "处理中", - (ushort)WorkflowResultCode.None => "-", - _ => "NG" - }; - } - - /// - /// 不支持反向转换。 - /// - /// 源值。 - /// 目标类型。 - /// 扩展参数。 - /// 当前区域信息。 - /// 抛出不支持异常。 - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } -} +using System.Globalization; +using System.Windows.Data; +using AxiOmron.PcbCheck.Models; + +namespace AxiOmron.PcbCheck.Converters; + +/// +/// 将 (以 存储)映射为简短 Tag 文本。 +/// +/// +/// 映射规则:Passed → "OK";Processing → "处理中";None → "-";其余视为 "NG"。 +/// +public sealed class ResultCodeToTagTextConverter : IValueConverter +{ + /// + /// 将结果码转换为 Tag 文本。 + /// + /// 结果码值。 + /// 目标类型。 + /// 扩展参数。 + /// 当前区域信息。 + /// 简短标签文本。 + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + ushort code = value switch + { + ushort u => u, + int i => (ushort)i, + WorkflowResultCode rc => (ushort)rc, + _ => (ushort)0 + }; + + return code switch + { + (ushort)WorkflowResultCode.Passed => "OK", + (ushort)WorkflowResultCode.Processing => "处理中", + (ushort)WorkflowResultCode.None => "-", + _ => "NG" + }; + } + + /// + /// 不支持反向转换。 + /// + /// 源值。 + /// 目标类型。 + /// 扩展参数。 + /// 当前区域信息。 + /// 抛出不支持异常。 + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs index f418e35..7157551 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs @@ -37,6 +37,7 @@ public sealed class DesignTimeAppConfigService : IAppConfigService ArgumentNullException.ThrowIfNull(config); _config.Plc = config.Plc; _config.Scanner = config.Scanner; + _config.FileCheck = config.FileCheck; _config.Sftp = config.Sftp; _config.Andon = config.Andon; _config.Workflow = config.Workflow; @@ -63,8 +64,9 @@ public sealed class DesignTimeAppConfigService : IAppConfigService Plc = new PlcOptions { Host = "192.168.10.25", - Port = 502, - UnitId = 1, + Port = 9600, + UnitAddress = 0, + SourceNodeAddress = 25, PollIntervalMs = 200, ConnectTimeoutMs = 3000, ReleasePulseMs = 450, @@ -72,15 +74,34 @@ public sealed class DesignTimeAppConfigService : IAppConfigService }, Scanner = new ScannerOptions { - PortName = "COM3", - BaudRate = 9600, - DataBits = 8, - Parity = "None", - StopBits = "One", + Scanner1 = new TcpScannerEndpointOptions + { + Name = "扫码枪1", + Host = "192.168.10.31", + Port = 9001 + }, + Scanner2 = new TcpScannerEndpointOptions + { + Name = "扫码枪2", + Host = "192.168.10.32", + Port = 9002 + }, + ConnectTimeoutMs = 3000, ReadTimeoutMs = 2500, - TriggerCommand = "SCAN\\r", + TriggerCommand = "T", ResponseTerminator = "\\r", - MaxScanAttempts = 3 + TriggerInParallel = true + }, + FileCheck = new FileCheckOptions + { + Provider = FileCheckProvider.Sftp, + FileNamePattern = "${barcode}.txt", + RetryIntervalSeconds = 2, + MaxRetryCount = 3, + Local = new LocalFileCheckOptions + { + RootPath = @"D:\PcbCheck\Files" + } }, Sftp = new SftpOptions { diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs index 0b7fcb0..69f5258 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs @@ -5,9 +5,9 @@ using AxiOmron.PcbCheck.Services.Interfaces; namespace AxiOmron.PcbCheck.DesignTime; /// -/// 为设计器提供 SFTP 测试结果的设计时服务。 +/// 为设计器提供文件校验通道测试结果的设计时服务。 /// -public sealed class DesignTimeSftpLookupService : ISftpLookupService +public sealed class DesignTimeSftpLookupService : IFileCheckService { /// /// 返回设计时文件校验结果。 @@ -15,9 +15,9 @@ public sealed class DesignTimeSftpLookupService : ISftpLookupService /// 条码。 /// 取消令牌。 /// 固定设计时结果。 - public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) + public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) { - return Task.FromResult(new SftpCheckOutcome + return Task.FromResult(new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, @@ -26,19 +26,19 @@ public sealed class DesignTimeSftpLookupService : ISftpLookupService } /// - /// 返回设计时连接测试结果。 + /// 返回设计时文件校验通道测试结果。 /// - /// SFTP 配置。 + /// 根配置。 /// 取消令牌。 /// 固定成功结果。 - public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) + public Task TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) { - return Task.FromResult(new SftpConnectionTestOutcome + return Task.FromResult(new FileCheckTestOutcome { IsSuccess = true, ConnectionSucceeded = true, RootPathAccessible = true, - StatusMessage = "设计时 SFTP 连接成功。" + StatusMessage = "设计时文件校验通道可用。" }); } } diff --git a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs index 633a0c9..3b1c1f5 100644 --- a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs +++ b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs @@ -26,7 +26,7 @@ public sealed class RuntimeSnapshot public string ScannerStatus { get; set; } = "未验证"; /// - /// 获取或设置 SFTP 连接状态文本。 + /// 获取或设置文件校验通道状态文本。 /// public string SftpStatus { get; set; } = "未验证"; diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs index cf53bed..a4fb15a 100644 --- a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs +++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs @@ -31,22 +31,22 @@ public enum WorkflowState ScanFailedReleased = 4, /// - /// 正在检查 SFTP。 + /// 正在检查文件。 /// CheckingSftp = 5, /// - /// 正在等待 SFTP 重试。 + /// 正在等待文件校验重试。 /// WaitingSftpRetry = 6, /// - /// SFTP 校验通过。 + /// 文件校验通过。 /// SftpPassed = 7, /// - /// SFTP 超时后放行。 + /// 文件校验超时后放行。 /// SftpTimeoutReleased = 8, @@ -118,12 +118,12 @@ public enum AlarmCode : ushort ScanFailed = 1001, /// - /// SFTP 文件超时未找到。 + /// 文件校验超时未找到。 /// FileNotFound = 1002, /// - /// SFTP 连接异常。 + /// 文件校验通道异常。 /// SftpFault = 1003, @@ -309,9 +309,9 @@ public sealed class ScanOperationResult } /// -/// 表示一次 SFTP 文件校验结果。 +/// 表示一次统一文件校验结果。 /// -public sealed class SftpCheckOutcome +public sealed class FileCheckOutcome { /// /// 获取或设置目标文件是否存在。 @@ -345,9 +345,9 @@ public sealed class SftpCheckOutcome } /// -/// 表示一次 SFTP 连接测试结果。 +/// 表示一次统一文件校验通道测试结果。 /// -public sealed class SftpConnectionTestOutcome +public sealed class FileCheckTestOutcome { /// /// 获取或设置连接测试是否成功。 @@ -489,7 +489,7 @@ public sealed class BoardProcessRecord public int ScanTryCount { get; set; } /// - /// 获取或设置 SFTP 查询次数。 + /// 获取或设置文件校验查询次数;字段名保留 SftpTryCount 以兼容历史 CSV。 /// public int SftpTryCount { get; set; } @@ -538,7 +538,7 @@ internal static class WorkflowStateExtensions WorkflowState.Scanning => "正在扫码", WorkflowState.ScanRetrying => "扫码失败,等待重试", WorkflowState.ScanFailedReleased => "扫码失败放行", - WorkflowState.CheckingSftp => "正在检查 SFTP 文件", + WorkflowState.CheckingSftp => "正在检查文件", WorkflowState.WaitingSftpRetry => "文件未命中,等待重试", WorkflowState.SftpPassed => "文件已找到,准备放行", WorkflowState.SftpTimeoutReleased => "文件未找到超时放行", diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index c8fdd3e..c995fad 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -15,6 +15,11 @@ public sealed class AppConfig /// public ScannerOptions Scanner { get; set; } = new(); + /// + /// 获取或设置统一文件校验配置。 + /// + public FileCheckOptions FileCheck { get; set; } = new(); + /// /// 获取或设置 SFTP 校验配置。 /// @@ -41,6 +46,64 @@ public sealed class AppConfig public SecurityOptions Security { get; set; } = new(); } +/// +/// 表示文件校验通道类型。 +/// +public enum FileCheckProvider +{ + /// + /// 使用 SFTP 目录作为文件校验来源。 + /// + Sftp, + + /// + /// 使用本地磁盘目录作为文件校验来源。 + /// + Local +} + +/// +/// 表示统一文件校验配置。 +/// +public sealed class FileCheckOptions +{ + /// + /// 获取或设置当前启用的文件校验通道。 + /// + public FileCheckProvider Provider { get; set; } = FileCheckProvider.Sftp; + + /// + /// 获取或设置文件名匹配模板。 + /// + public string FileNamePattern { get; set; } = "${barcode}.txt"; + + /// + /// 获取或设置首次未命中后的重试间隔,单位为秒。 + /// + public int RetryIntervalSeconds { get; set; } = 2; + + /// + /// 获取或设置首次未命中后的最大重试次数。 + /// + public int MaxRetryCount { get; set; } = 3; + + /// + /// 获取或设置本地磁盘文件校验配置。 + /// + public LocalFileCheckOptions Local { get; set; } = new(); +} + +/// +/// 表示本地磁盘文件校验配置。 +/// +public sealed class LocalFileCheckOptions +{ + /// + /// 获取或设置本地文件校验根目录。 + /// + public string RootPath { get; set; } = "D:\\PcbCheck\\Files"; +} + /// /// 表示 PLC 通信参数与点位映射配置。 /// @@ -54,12 +117,17 @@ public sealed class PlcOptions /// /// 获取或设置 PLC 端口。 /// - public int Port { get; set; } = 502; + public int Port { get; set; } = 9600; /// - /// 获取或设置 Modbus 从站号。 + /// 获取或设置 FINS 目标单元地址。 /// - public byte UnitId { get; set; } = 1; + public byte UnitAddress { get; set; } + + /// + /// 获取或设置上位机节点编号。 + /// + public byte SourceNodeAddress { get; set; } = 25; /// /// 获取或设置轮询周期,单位为毫秒。 @@ -98,54 +166,75 @@ public sealed class PlcOptions } /// -/// 表示扫码枪串口参数配置。 +/// 表示双 TCP 扫码枪参数配置。 /// public sealed class ScannerOptions { /// - /// 获取或设置串口号。 + /// 获取或设置扫码枪1配置。 /// - public string PortName { get; set; } = "COM1"; + public TcpScannerEndpointOptions Scanner1 { get; set; } = new() + { + Name = "扫码枪1", + Host = "127.0.0.1", + Port = 9001 + }; /// - /// 获取或设置波特率。 + /// 获取或设置扫码枪2配置。 /// - public int BaudRate { get; set; } = 9600; + public TcpScannerEndpointOptions Scanner2 { get; set; } = new() + { + Name = "扫码枪2", + Host = "127.0.0.1", + Port = 9002 + }; /// - /// 获取或设置数据位。 + /// 获取或设置 TCP 连接超时,单位为毫秒。 /// - public int DataBits { get; set; } = 8; + public int ConnectTimeoutMs { get; set; } = 3000; /// - /// 获取或设置校验位名称。 + /// 获取或设置扫码枪触发命令,支持转义字符。 /// - public string Parity { get; set; } = "None"; + public string TriggerCommand { get; set; } = "T"; /// - /// 获取或设置停止位名称。 + /// 获取或设置返回报文结束符,支持转义字符;为空时读取到首批非空数据即返回。 /// - public string StopBits { get; set; } = "One"; + public string ResponseTerminator { get; set; } = "\\r"; + + /// + /// 获取或设置是否并发触发两把扫码枪。 + /// + public bool TriggerInParallel { get; set; } = true; /// /// 获取或设置单次扫码超时,单位为毫秒。 /// public int ReadTimeoutMs { get; set; } = 3000; +} + +/// +/// 表示单把 TCP 扫码枪连接参数。 +/// +public sealed class TcpScannerEndpointOptions +{ + /// + /// 获取或设置扫码枪名称。 + /// + public string Name { get; set; } = string.Empty; /// - /// 获取或设置触发命令,支持转义字符。 + /// 获取或设置扫码枪 IP 或主机名。 /// - public string TriggerCommand { get; set; } = "SCAN\\r"; + public string Host { get; set; } = "127.0.0.1"; /// - /// 获取或设置返回报文结束符,支持转义字符。 + /// 获取或设置扫码枪 TCP 端口。 /// - public string ResponseTerminator { get; set; } = "\\r"; - - /// - /// 获取或设置最大扫码尝试次数。 - /// - public int MaxScanAttempts { get; set; } = 3; + public int Port { get; set; } = 9001; } /// @@ -331,33 +420,33 @@ public sealed class PlcInputAddressOptions /// /// 获取或设置 PCB 到位点位地址。 /// - public int PcbArrived { get; set; } = 10001; + public int PcbArrived { get; set; } = 1001; /// /// 获取或设置 PLC 复位点位地址。 /// - public int PlcReset { get; set; } = 10002; + public int PlcReset { get; set; } = 1002; /// /// 获取或设置 PLC 放行应答点位地址。 /// - public int PlcAckRelease { get; set; } = 10003; + public int PlcAckRelease { get; set; } = 1003; } /// -/// 表示 PLC 输出线圈地址配置。 +/// 表示 PLC 输出地址配置。 /// public sealed class PlcOutputAddressOptions { /// /// 获取或设置 PC 忙碌位地址。 /// - public int PcBusy { get; set; } = 51; + public int PcBusy { get; set; } = 1051; /// /// 获取或设置放行位地址。 /// - public int ReleasePermit { get; set; } = 52; + public int ReleasePermit { get; set; } = 1052; } /// @@ -368,7 +457,5 @@ public sealed class PlcRegisterAddressOptions /// /// 获取或设置结果代码寄存器地址。 /// - public int ResultCode { get; set; } = 40001; - - /// + public int ResultCode { get; set; } = 1100; } diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs index 8fc88a4..c6462c9 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppConfigService.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; @@ -13,7 +14,11 @@ public sealed class AppConfigService : IAppConfigService private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, - WriteIndented = true + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter() + } }; /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/DualScannerResultMerger.cs b/src/AxiOmron.PcbCheck/Services/Implementations/DualScannerResultMerger.cs new file mode 100644 index 0000000..ac183ea --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/DualScannerResultMerger.cs @@ -0,0 +1,97 @@ +using AxiOmron.PcbCheck.Models; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供双扫码枪结果汇总能力。 +/// +public static class DualScannerResultMerger +{ + /// + /// 按“任一成功即可、双成功不一致时优先扫码枪1”的规则汇总两路扫码结果。 + /// + /// 扫码枪1结果。 + /// 扫码枪2结果。 + /// 汇总后的扫码结果。 + public static ScanOperationResult Merge(ScanOperationResult first, ScanOperationResult second) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + + if (first.IsSuccess && second.IsSuccess) + { + return MergeBothSucceeded(first, second); + } + + if (first.IsSuccess) + { + return CreateSuccess("扫码枪1", first, second); + } + + if (second.IsSuccess) + { + return CreateSuccess("扫码枪2", second, first); + } + + return new ScanOperationResult + { + IsSuccess = false, + IsSystemError = first.IsSystemError && second.IsSystemError, + DeviceConnected = first.DeviceConnected || second.DeviceConnected, + RawMessage = BuildRawMessage(first, second), + ErrorMessage = $"扫码枪1失败: {first.ErrorMessage}; 扫码枪2失败: {second.ErrorMessage}", + DurationMs = Math.Max(first.DurationMs, second.DurationMs) + }; + } + + /// + /// 汇总两把扫码枪均成功的场景。 + /// + /// 扫码枪1结果。 + /// 扫码枪2结果。 + /// 汇总后的扫码结果。 + private static ScanOperationResult MergeBothSucceeded(ScanOperationResult first, ScanOperationResult second) + { + var sameBarcode = string.Equals(first.Barcode, second.Barcode, StringComparison.Ordinal); + return new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = first.DeviceConnected || second.DeviceConnected, + Barcode = first.Barcode, + RawMessage = BuildRawMessage(first, second), + ErrorMessage = sameBarcode ? string.Empty : $"两把扫码枪条码不一致,已优先使用扫码枪1条码。扫码枪2={second.Barcode}", + DurationMs = Math.Max(first.DurationMs, second.DurationMs) + }; + } + + /// + /// 创建单路成功的汇总结果。 + /// + /// 成功来源名称。 + /// 成功结果。 + /// 另一路结果。 + /// 汇总后的扫码结果。 + private static ScanOperationResult CreateSuccess(string sourceName, ScanOperationResult success, ScanOperationResult other) + { + return new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = success.DeviceConnected || other.DeviceConnected, + Barcode = success.Barcode, + RawMessage = $"使用{sourceName}; {BuildRawMessage(sourceName == "扫码枪1" ? success : other, sourceName == "扫码枪1" ? other : success)}", + ErrorMessage = other.IsSuccess ? string.Empty : other.ErrorMessage, + DurationMs = Math.Max(success.DurationMs, other.DurationMs) + }; + } + + /// + /// 构造包含两路扫码原始信息的追溯文本。 + /// + /// 扫码枪1结果。 + /// 扫码枪2结果。 + /// 两路原始信息。 + private static string BuildRawMessage(ScanOperationResult first, ScanOperationResult second) + { + return $"扫码枪1: 条码={first.Barcode}, 原始={first.RawMessage}, 错误={first.ErrorMessage}; 扫码枪2: 条码={second.Barcode}, 原始={second.RawMessage}, 错误={second.ErrorMessage}"; + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs new file mode 100644 index 0000000..98fb0ad --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs @@ -0,0 +1,35 @@ +using AxiOmron.PcbCheck.Utils; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供文件校验通道共用的文件名模板与匹配规则。 +/// +internal static class FileCheckPathMatcher +{ + /// + /// 根据条码和模板构建预期文件名。 + /// + /// 条码内容。 + /// 文件名模板。 + /// 预期文件名或匹配模式。 + public static string BuildExpectedFileName(string barcode, string pattern) + { + string effectivePattern = string.IsNullOrWhiteSpace(pattern) ? "${barcode}.txt" : pattern; + return effectivePattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 判断文件名是否符合当前条码和候选模板。 + /// + /// 待检查文件名。 + /// 由模板生成的候选文件名。 + /// 条码内容。 + /// 匹配返回 ;否则返回 。 + public static bool IsMatch(string fileName, string candidateName, string barcode) + { + return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase) + || WildcardMatcher.IsMatch(fileName, candidateName) + || fileName.Contains(barcode, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs new file mode 100644 index 0000000..24fed6b --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs @@ -0,0 +1,91 @@ +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 根据当前配置将文件校验请求路由到具体通道提供程序。 +/// +public sealed class FileCheckService : IFileCheckService +{ + private readonly AppConfig _config; + private readonly IReadOnlyDictionary _providers; + + /// + /// 初始化文件校验路由服务。 + /// + /// 应用根配置。 + /// 文件校验通道提供程序集合。 + /// 当 或 为 时抛出。 + public FileCheckService(AppConfig config, IEnumerable providers) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + ArgumentNullException.ThrowIfNull(providers); + _providers = providers.ToDictionary(provider => provider.Provider); + } + + /// + /// 按条码检查目标文件是否存在。 + /// + /// 条码内容。 + /// 取消令牌。取消后立即终止当前通道校验。 + /// 文件校验结果。 + public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) + { + IFileCheckProvider? provider = ResolveProvider(_config.FileCheck.Provider); + if (provider is null) + { + return Task.FromResult(CreateUnsupportedProviderOutcome(_config.FileCheck.Provider)); + } + + return provider.CheckFileAsync(barcode, _config, cancellationToken); + } + + /// + /// 测试当前或指定配置下的文件校验通道可用性。 + /// + /// 待测试的根配置;为 时使用当前应用配置。 + /// 取消令牌。取消后立即终止当前通道测试。 + /// 通道测试结果。 + public Task TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) + { + AppConfig effectiveConfig = config ?? _config; + IFileCheckProvider? provider = ResolveProvider(effectiveConfig.FileCheck.Provider); + if (provider is null) + { + return Task.FromResult(new FileCheckTestOutcome + { + IsConfigurationError = true, + StatusMessage = $"不支持的文件校验通道: {effectiveConfig.FileCheck.Provider}" + }); + } + + return provider.TestConnectionAsync(effectiveConfig, cancellationToken); + } + + /// + /// 根据通道类型查找对应提供程序。 + /// + /// 通道类型。 + /// 提供程序;不存在时返回 。 + private IFileCheckProvider? ResolveProvider(FileCheckProvider provider) + { + return _providers.TryGetValue(provider, out IFileCheckProvider? service) ? service : null; + } + + /// + /// 创建不支持通道的文件校验结果。 + /// + /// 通道类型。 + /// 文件校验结果。 + private static FileCheckOutcome CreateUnsupportedProviderOutcome(FileCheckProvider provider) + { + return new FileCheckOutcome + { + Exists = false, + IsConfigurationError = true, + ErrorMessage = $"不支持的文件校验通道: {provider}" + }; + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs b/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs new file mode 100644 index 0000000..b18dae4 --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs @@ -0,0 +1,179 @@ +using System.IO; +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供本地磁盘目录的文件存在性校验能力。 +/// +public sealed class LocalFileCheckProvider : IFileCheckProvider +{ + private readonly IAppLogger _logger; + + /// + /// 初始化本地文件校验提供程序。 + /// + /// 日志记录器。 + public LocalFileCheckProvider(IAppLogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 获取当前提供程序对应的通道类型。 + /// + public FileCheckProvider Provider => FileCheckProvider.Local; + + /// + /// 按条码检查本地目录中是否存在目标文件。 + /// + /// 条码内容。 + /// 根配置对象。 + /// 取消令牌。取消后立即终止目录枚举。 + /// 文件校验结果。 + public async Task CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(config); + if (string.IsNullOrWhiteSpace(barcode)) + { + return new FileCheckOutcome + { + Exists = false, + IsConfigurationError = true, + ErrorMessage = "条码为空,无法执行本地文件查询。" + }; + } + + FileCheckTestOutcome testOutcome = ValidateRootPath(config.FileCheck.Local.RootPath); + if (!testOutcome.IsSuccess) + { + return new FileCheckOutcome + { + Exists = false, + IsConfigurationError = testOutcome.IsConfigurationError, + IsSystemError = testOutcome.IsSystemError, + ErrorMessage = testOutcome.StatusMessage + }; + } + + return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + /// + /// 测试本地文件校验目录是否可访问。 + /// + /// 根配置对象。 + /// 取消令牌。取消后立即终止测试。 + /// 通道测试结果。 + public Task TestConnectionAsync(AppConfig config, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(config); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(ValidateRootPath(config.FileCheck.Local.RootPath)); + } + + /// + /// 在同步上下文中执行本地文件查找。 + /// + /// 条码内容。 + /// 根配置对象。 + /// 取消令牌。 + /// 文件校验结果。 + private FileCheckOutcome CheckInternal(string barcode, AppConfig config, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string rootPath = Path.GetFullPath(config.FileCheck.Local.RootPath); + string candidateName = FileCheckPathMatcher.BuildExpectedFileName(barcode, config.FileCheck.FileNamePattern); + string? matchedPath = Directory.EnumerateFiles(rootPath) + .FirstOrDefault(path => + { + cancellationToken.ThrowIfCancellationRequested(); + return FileCheckPathMatcher.IsMatch(Path.GetFileName(path), candidateName, barcode); + }); + + if (matchedPath is null) + { + return new FileCheckOutcome + { + Exists = false, + ConnectionSucceeded = true, + ErrorMessage = $"未找到与条码 {barcode} 匹配的本地文件。" + }; + } + + return new FileCheckOutcome + { + Exists = true, + ConnectionSucceeded = true, + MatchedFilePath = matchedPath + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + _logger.LogError(ex, "本地文件校验异常"); + return new FileCheckOutcome + { + Exists = false, + IsSystemError = true, + ErrorMessage = $"本地文件校验异常: {ex.Message}" + }; + } + } + + /// + /// 验证本地根目录配置和可访问性。 + /// + /// 本地根目录。 + /// 通道测试结果。 + private static FileCheckTestOutcome ValidateRootPath(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + return new FileCheckTestOutcome + { + IsConfigurationError = true, + StatusMessage = "本地文件校验目录未配置。" + }; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(rootPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return new FileCheckTestOutcome + { + IsConfigurationError = true, + StatusMessage = $"本地文件校验目录配置无效: {ex.Message}" + }; + } + + if (!Directory.Exists(fullPath)) + { + return new FileCheckTestOutcome + { + IsConfigurationError = true, + StatusMessage = $"本地文件校验目录不存在: {fullPath}" + }; + } + + return new FileCheckTestOutcome + { + IsSuccess = true, + ConnectionSucceeded = true, + RootPathAccessible = true, + StatusMessage = $"本地文件校验目录可访问: {fullPath}" + }; + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs similarity index 65% rename from src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs rename to src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs index 7c3d1d3..f22c7fc 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/ModbusTcpPlcService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs @@ -1,28 +1,29 @@ using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; -using IoTClient.Clients.Modbus; +using IoTClient.Clients.PLC; using IoTClient.Enums; + namespace AxiOmron.PcbCheck.Services.Implementations; /// -/// 提供基于 IoTClient ModbusTcpClient 的 PLC 读写能力。 +/// 提供基于 IoTClient OmronFinsClient 的 PLC 读写能力。 /// -public sealed class ModbusTcpPlcService : IPlcService, IDisposable +public sealed class OmronFinsPlcService : IPlcService, IDisposable { private readonly PlcOptions _options; - private readonly IAppLogger _logger; + private readonly IAppLogger _logger; private readonly SemaphoreSlim _ioLock = new(1, 1); - private ModbusTcpClient? _client; + private OmronFinsClient? _client; private PlcProcessState? _lastWrittenState; private bool _disposed; /// - /// 初始化 PLC 通信服务。 + /// 初始化 OMRON FINS PLC 通信服务。 /// /// 应用根配置。 /// 日志记录器。 - public ModbusTcpPlcService(AppConfig config, IAppLogger logger) + public OmronFinsPlcService(AppConfig config, IAppLogger logger) { ArgumentNullException.ThrowIfNull(config); _options = config.Plc; @@ -44,9 +45,9 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable return new PlcSignalSnapshot { - PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived), - PlcReset = ReadDiscrete(_options.Inputs.PlcReset), - PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease), + PcbArrived = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PcbArrived)), + PlcReset = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcReset)), + PlcAckRelease = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcAckRelease)), CapturedAt = DateTimeOffset.Now }; } @@ -79,16 +80,16 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable { Inputs = new PlcSignalSnapshot { - PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived), - PlcReset = ReadDiscrete(_options.Inputs.PlcReset), - PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease), + PcbArrived = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PcbArrived)), + PlcReset = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcReset)), + PlcAckRelease = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcAckRelease)), CapturedAt = capturedAt }, Outputs = new PlcProcessState { - PcBusy = ReadCoil(_options.Outputs.PcBusy), - ReleasePermit = ReadCoil(_options.Outputs.ReleasePermit), - ResultCode = ReadRegister(_options.Registers.ResultCode) + PcBusy = ReadBoolean(ToDataMemoryAddress(_options.Outputs.PcBusy)), + ReleasePermit = ReadBoolean(ToDataMemoryAddress(_options.Outputs.ReleasePermit)), + ResultCode = ReadUInt16(ToDataMemoryAddress(_options.Registers.ResultCode)) }, CapturedAt = capturedAt }; @@ -170,7 +171,7 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable } /// - /// 确保 IoTClient Modbus TCP 客户端已连接。 + /// 确保 IoTClient FINS 客户端已连接。 /// private void EnsureConnected() { @@ -180,73 +181,62 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable } DisconnectUnsafe(); - var client = new ModbusTcpClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD, false); + var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD) + { + UnitAddress = _options.UnitAddress, + SA1 = _options.SourceNodeAddress + }; var openResult = client.Open(); - EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 PLC 失败"); + EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败"); _client = client; - _logger.LogInformation("已通过 IoTClient 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port); + _logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port); } /// - /// 读取单个离散输入位。 + /// 读取单个 D 区布尔值。 /// - /// 离散输入地址。 + /// FINS 地址。 /// 读取到的布尔值。 - private bool ReadDiscrete(int address) + private bool ReadBoolean(string address) { var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); - var result = client.ReadDiscrete(address, _options.UnitId, 2); - EnsureSuccess(result.IsSucceed, result.Err, $"读取离散输入失败,地址={address}"); + var result = client.ReadBoolean(address); + EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS 布尔值失败,地址={address}"); return result.Value; } /// - /// 读取单个输出线圈位。 + /// 读取单个 D 区无符号整数。 /// - /// 线圈地址。 - /// 读取到的布尔值。 - 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; - } - - /// - /// 读取单个保持寄存器。 - /// - /// 寄存器地址。 + /// FINS 地址。 /// 读取到的寄存器值。 - private ushort ReadRegister(int address) + private ushort ReadUInt16(string address) { var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); - var result = client.ReadUInt16(address, _options.UnitId, 3); - EnsureSuccess(result.IsSucceed, result.Err, $"读取保持寄存器失败,地址={address}"); + var result = client.ReadUInt16(address); + EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS UInt16 失败,地址={address}"); return result.Value; } /// - /// 写入所有发生变化的线圈位与寄存器。 + /// 写入所有发生变化的输出位与寄存器。 /// /// 目标状态。 private void WriteChangedState(PlcProcessState state) { var previous = _lastWrittenState; - - WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy); - WriteSingleCoilIfChanged(previous?.ReleasePermit, state.ReleasePermit, _options.Outputs.ReleasePermit); - - WriteSingleRegisterIfChanged(previous?.ResultCode, state.ResultCode, _options.Registers.ResultCode); + WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy)); + WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit)); + WriteUInt16IfChanged(previous?.ResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode)); } /// - /// 仅当值发生变化时写入单个线圈位。 + /// 仅当值发生变化时写入单个 D 区布尔值。 /// /// 上一值。 /// 当前值。 - /// 线圈地址。 - private void WriteSingleCoilIfChanged(bool? previous, bool current, int address) + /// FINS 地址。 + private void WriteBooleanIfChanged(bool? previous, bool current, string address) { if (previous.HasValue && previous.Value == current) { @@ -254,26 +244,36 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable } var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); - var result = client.Write(address.ToString(), current, _options.UnitId, 5); - EnsureSuccess(result.IsSucceed, result.Err, $"写入线圈失败,地址={address},值={current}"); + var result = client.Write(address, current); + EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS 布尔值失败,地址={address},值={current}"); } /// - /// 仅当值发生变化时写入单个保持寄存器。 + /// 仅当值发生变化时写入单个 D 区无符号整数。 /// /// 上一值。 /// 当前值。 - /// 保持寄存器地址。 - private void WriteSingleRegisterIfChanged(ushort? previous, ushort current, int address) + /// FINS 地址。 + private void WriteUInt16IfChanged(ushort? previous, ushort current, string address) { if (previous.HasValue && previous.Value == current) { return; } - var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); - var result = client.Write(address.ToString(), current, _options.UnitId); - EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}"); + var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); + var result = client.Write(address, current); + EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}"); + } + + /// + /// 将数字形式 D 区地址转换为 IoTClient FINS 地址字符串。 + /// + /// D 区地址编号。 + /// FINS 地址字符串。 + private static string ToDataMemoryAddress(int address) + { + return $"D{address}"; } /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs deleted file mode 100644 index 80d3972..0000000 --- a/src/AxiOmron.PcbCheck/Services/Implementations/SerialScannerService.cs +++ /dev/null @@ -1,319 +0,0 @@ -using System.Diagnostics; -using System.IO; -using System.IO.Ports; -using System.Text; -using AxiOmron.PcbCheck.Models; -using AxiOmron.PcbCheck.Options; -using AxiOmron.PcbCheck.Services.Interfaces; -using AxiOmron.PcbCheck.Utils; - -namespace AxiOmron.PcbCheck.Services.Implementations; - -/// -/// 提供串口扫码枪的触发与读取能力。 -/// -public sealed class SerialScannerService : IScannerService, IDisposable -{ - private readonly ScannerOptions _options; - private readonly IAppLogger _logger; - private readonly SemaphoreSlim _ioLock = new(1, 1); - private SerialPort? _serialPort; - private bool _disposed; - - /// - /// 初始化扫码枪服务。 - /// - /// 应用根配置。 - /// 日志记录器。 - public SerialScannerService(AppConfig config, IAppLogger logger) - { - ArgumentNullException.ThrowIfNull(config); - _options = config.Scanner; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 触发一次扫码。 - /// - /// 取消令牌。 - /// 扫码结果。 - public async Task TriggerScanAsync(CancellationToken cancellationToken) - { - var lockTaken = false; - try - { - await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false); - lockTaken = true; - return await TriggerScanInternalAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "扫码操作失败"); - return new ScanOperationResult - { - IsSuccess = false, - IsSystemError = ex is not OperationCanceledException, - DeviceConnected = false, - ErrorMessage = ex.Message - }; - } - finally - { - if (lockTaken) - { - _ioLock.Release(); - } - } - } - - /// - /// 测试扫码枪连接。 - /// - /// 取消令牌。 - /// 连接正常返回 ;否则返回 。 - public async Task TestConnectionAsync(CancellationToken cancellationToken) - { - var lockTaken = false; - try - { - await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false); - lockTaken = true; - cancellationToken.ThrowIfCancellationRequested(); - EnsurePortOpen(); - return _serialPort is { IsOpen: true }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "扫码枪连接测试失败"); - ClosePortUnsafe(); - return false; - } - finally - { - if (lockTaken) - { - _ioLock.Release(); - } - } - } - - /// - /// 主动断开并重建扫码枪连接。 - /// - /// 取消令牌。 - /// 表示重连完成的任务。 - public async Task ForceReconnectAsync(CancellationToken cancellationToken) - { - var lockTaken = false; - try - { - await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false); - lockTaken = true; - cancellationToken.ThrowIfCancellationRequested(); - ClosePortUnsafe(); - EnsurePortOpen(); - } - catch (Exception ex) - { - _logger.LogError(ex, "扫码枪强制重连失败"); - ClosePortUnsafe(); - } - finally - { - if (lockTaken) - { - _ioLock.Release(); - } - } - } - - /// - /// 释放扫码枪串口资源。 - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - ClosePortUnsafe(); - _ioLock.Dispose(); - } - - /// - /// 在异步上下文中执行一次扫码流程。 - /// - /// 取消令牌。 - /// 扫码结果。 - private async Task TriggerScanInternalAsync(CancellationToken cancellationToken) - { - var stopwatch = Stopwatch.StartNew(); - try - { - EnsurePortOpen(); - var port = _serialPort ?? throw new InvalidOperationException("扫码枪串口尚未初始化。"); - port.DiscardInBuffer(); - port.DiscardOutBuffer(); - port.Write(StringEscapeHelper.Unescape(_options.TriggerCommand)); - - var rawMessage = await ReadUntilTerminatorAsync( - port, - StringEscapeHelper.Unescape(_options.ResponseTerminator), - _options.ReadTimeoutMs, - cancellationToken).ConfigureAwait(false); - var barcode = BarcodeCleaner.Clean(rawMessage); - if (string.IsNullOrEmpty(barcode)) - { - return new ScanOperationResult - { - IsSuccess = false, - DeviceConnected = true, - RawMessage = rawMessage, - ErrorMessage = "扫码返回空字符串或仅包含控制字符。", - DurationMs = stopwatch.ElapsedMilliseconds - }; - } - - return new ScanOperationResult - { - IsSuccess = true, - DeviceConnected = true, - Barcode = barcode, - RawMessage = rawMessage, - DurationMs = stopwatch.ElapsedMilliseconds - }; - } - catch (TimeoutException ex) - { - _logger.LogWarning(ex, "扫码等待超时"); - return new ScanOperationResult - { - IsSuccess = false, - DeviceConnected = true, - ErrorMessage = "扫码超时。", - DurationMs = stopwatch.ElapsedMilliseconds - }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "扫码枪执行失败"); - ClosePortUnsafe(); - return new ScanOperationResult - { - IsSuccess = false, - IsSystemError = true, - DeviceConnected = false, - ErrorMessage = ex.Message, - DurationMs = stopwatch.ElapsedMilliseconds - }; - } - } - - /// - /// 确保串口已打开并按当前配置初始化。 - /// - private void EnsurePortOpen() - { - if (_serialPort is { IsOpen: true }) - { - return; - } - - ClosePortUnsafe(); - - var availablePorts = SerialPort.GetPortNames(); - if (!availablePorts.Contains(_options.PortName, StringComparer.OrdinalIgnoreCase)) - { - _logger.LogWarning("本地不存在串口 {PortName},可用串口: {AvailablePorts}", false, _options.PortName, string.Join(", ", availablePorts)); - throw new IOException($"串口 {_options.PortName} 不存在。可用串口: {string.Join(", ", availablePorts)}"); - } - - var parity = Enum.Parse(_options.Parity, true); - var stopBits = Enum.Parse(_options.StopBits, true); - var serialPort = new SerialPort(_options.PortName, _options.BaudRate, parity, _options.DataBits, stopBits) - { - ReadTimeout = 200, - WriteTimeout = 1000, - Encoding = Encoding.ASCII, - DtrEnable = true, - RtsEnable = true, - NewLine = StringEscapeHelper.Unescape(_options.ResponseTerminator) - }; - - try - { - serialPort.Open(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "打开串口 {PortName} 失败", false, _options.PortName); - serialPort.Dispose(); - throw; - } - - _serialPort = serialPort; - _logger.LogInformation("已连接扫码枪串口 {PortName}", false, _options.PortName); - } - - /// - /// 从串口读取直到遇到终止符或超时。 - /// - /// 串口实例。 - /// 终止符。 - /// 总超时时间,单位为毫秒。 - /// 取消令牌。 - /// 读取到的原始字符串。 - private static async Task ReadUntilTerminatorAsync(SerialPort port, string terminator, int timeoutMs, CancellationToken cancellationToken) - { - var stopwatch = Stopwatch.StartNew(); - var buffer = new StringBuilder(); - - while (stopwatch.ElapsedMilliseconds < timeoutMs) - { - cancellationToken.ThrowIfCancellationRequested(); - var fragment = port.ReadExisting(); - if (!string.IsNullOrEmpty(fragment)) - { - buffer.Append(fragment); - if (string.IsNullOrEmpty(terminator) || buffer.ToString().Contains(terminator, StringComparison.Ordinal)) - { - return buffer.ToString(); - } - } - - await Task.Delay(20, cancellationToken).ConfigureAwait(false); - } - - throw new TimeoutException("扫码枪在规定时间内未返回完整报文。"); - } - - /// - /// 关闭当前串口并释放资源。 - /// - private void ClosePortUnsafe() - { - try - { - if (_serialPort?.IsOpen == true) - { - _serialPort.Close(); - } - } - catch - { - // 忽略关闭异常。 - } - finally - { - _serialPort?.Dispose(); - _serialPort = null; - } - } -} - diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs index 5c89a8e..eae5cd4 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs @@ -1,7 +1,6 @@ using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; -using AxiOmron.PcbCheck.Utils; using Renci.SshNet; using Renci.SshNet.Common; @@ -10,9 +9,8 @@ namespace AxiOmron.PcbCheck.Services.Implementations; /// /// 提供 SFTP 文件存在性校验能力。 /// -public sealed class SftpLookupService : ISftpLookupService +public sealed class SftpLookupService : IFileCheckProvider { - private readonly SftpOptions _options; private readonly IAppLogger _logger; /// @@ -20,24 +18,28 @@ public sealed class SftpLookupService : ISftpLookupService /// /// 应用根配置。 /// 日志记录器。 - public SftpLookupService(AppConfig config, IAppLogger logger) + public SftpLookupService(IAppLogger logger) { - ArgumentNullException.ThrowIfNull(config); - _options = config.Sftp; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// 获取当前提供程序对应的通道类型。 + /// + public FileCheckProvider Provider => FileCheckProvider.Sftp; + /// /// 按条码检查目标文件是否存在。 /// /// 条码内容。 /// 取消令牌。 /// 文件校验结果。 - public async Task CheckFileAsync(string barcode, CancellationToken cancellationToken) + public async Task CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(config); if (string.IsNullOrWhiteSpace(barcode)) { - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsConfigurationError = true, @@ -45,9 +47,9 @@ public sealed class SftpLookupService : ISftpLookupService }; } - if (string.IsNullOrWhiteSpace(_options.Host) || string.IsNullOrWhiteSpace(_options.RootPath)) + if (string.IsNullOrWhiteSpace(config.Sftp.Host) || string.IsNullOrWhiteSpace(config.Sftp.RootPath)) { - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsConfigurationError = true, @@ -55,21 +57,22 @@ public sealed class SftpLookupService : ISftpLookupService }; } - return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false); + return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); } /// /// 测试当前或指定配置下的 SFTP 连接可用性。 /// - /// 待测试的 SFTP 配置;为 时使用当前应用配置。 + /// 待测试的根配置。 /// 取消令牌。若在连接过程中取消,将终止测试。 /// 连接测试结果。 - public async Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) + public async Task TestConnectionAsync(AppConfig config, CancellationToken cancellationToken) { - SftpOptions effectiveOptions = options ?? _options; + ArgumentNullException.ThrowIfNull(config); + SftpOptions effectiveOptions = config.Sftp; if (string.IsNullOrWhiteSpace(effectiveOptions.Host) || string.IsNullOrWhiteSpace(effectiveOptions.RootPath)) { - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsConfigurationError = true, StatusMessage = "SFTP 配置缺失 Host 或 RootPath。" @@ -85,18 +88,19 @@ public sealed class SftpLookupService : ISftpLookupService /// 条码。 /// 取消令牌。 /// 查询结果。 - private SftpCheckOutcome CheckInternal(string barcode, CancellationToken cancellationToken) + private FileCheckOutcome CheckInternal(string barcode, AppConfig config, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { - using var client = CreateClient(_options); - ConnectClient(client, _options); + SftpOptions options = config.Sftp; + using var client = CreateClient(options); + ConnectClient(client, options); if (!client.IsConnected) { - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsSystemError = true, @@ -104,11 +108,11 @@ public sealed class SftpLookupService : ISftpLookupService }; } - var candidateName = BuildExpectedFileName(barcode); - var rootPath = NormalizeDirectory(_options.RootPath); + string candidateName = FileCheckPathMatcher.BuildExpectedFileName(barcode, config.FileCheck.FileNamePattern); + var rootPath = NormalizeDirectory(options.RootPath); if (!client.Exists(rootPath)) { - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsConfigurationError = true, @@ -119,14 +123,11 @@ public sealed class SftpLookupService : ISftpLookupService var matched = client.ListDirectory(rootPath) .Where(entry => !entry.IsDirectory && !entry.IsSymbolicLink) - .FirstOrDefault(entry => - entry.Name.Equals(candidateName, StringComparison.OrdinalIgnoreCase) - || WildcardMatcher.IsMatch(entry.Name, candidateName) - || entry.Name.Contains(barcode, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(entry => FileCheckPathMatcher.IsMatch(entry.Name, candidateName, barcode)); if (matched is null) { - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, ConnectionSucceeded = true, @@ -134,7 +135,7 @@ public sealed class SftpLookupService : ISftpLookupService }; } - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, @@ -148,7 +149,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (SshAuthenticationException ex) { _logger.LogError(ex, "SFTP 认证失败"); - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsSystemError = true, @@ -158,7 +159,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (SshConnectionException ex) { _logger.LogError(ex, "SFTP 连接失败"); - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsSystemError = true, @@ -168,7 +169,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (Exception ex) { _logger.LogError(ex, "SFTP 查询异常"); - return new SftpCheckOutcome + return new FileCheckOutcome { Exists = false, IsSystemError = true, @@ -177,15 +178,6 @@ public sealed class SftpLookupService : ISftpLookupService } } - /// - /// 根据当前配置创建 SFTP 客户端。 - /// - /// SFTP 客户端实例。 - private SftpClient CreateClient() - { - return CreateClient(_options); - } - /// /// 根据指定配置创建 SFTP 客户端。 /// @@ -218,17 +210,6 @@ public sealed class SftpLookupService : ISftpLookupService client.Connect(); } - /// - /// 根据条码和模板构建预期文件名。 - /// - /// 条码。 - /// 预期文件名或匹配模式。 - private string BuildExpectedFileName(string barcode) - { - var pattern = string.IsNullOrWhiteSpace(_options.FileNamePattern) ? "${barcode}.txt" : _options.FileNamePattern; - return pattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase); - } - /// /// 统一目录路径格式。 /// @@ -245,7 +226,7 @@ public sealed class SftpLookupService : ISftpLookupService /// 待测试的配置。 /// 取消令牌。 /// 连接测试结果。 - private SftpConnectionTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken) + private FileCheckTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -256,7 +237,7 @@ public sealed class SftpLookupService : ISftpLookupService if (!client.IsConnected) { - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsSystemError = true, StatusMessage = "SFTP 未能建立连接。" @@ -266,7 +247,7 @@ public sealed class SftpLookupService : ISftpLookupService var rootPath = NormalizeDirectory(options.RootPath); if (!client.Exists(rootPath)) { - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsConfigurationError = true, ConnectionSucceeded = true, @@ -274,7 +255,7 @@ public sealed class SftpLookupService : ISftpLookupService }; } - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsSuccess = true, ConnectionSucceeded = true, @@ -289,7 +270,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (SshAuthenticationException ex) { _logger.LogError(ex, "SFTP 认证失败"); - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsSystemError = true, StatusMessage = $"SFTP 认证失败: {ex.Message}" @@ -298,7 +279,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (SshConnectionException ex) { _logger.LogError(ex, "SFTP 连接失败"); - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsSystemError = true, StatusMessage = $"SFTP 连接失败: {ex.Message}" @@ -307,7 +288,7 @@ public sealed class SftpLookupService : ISftpLookupService catch (Exception ex) { _logger.LogError(ex, "SFTP 连接测试异常"); - return new SftpConnectionTestOutcome + return new FileCheckTestOutcome { IsSystemError = true, StatusMessage = ex.Message diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs new file mode 100644 index 0000000..684c5d0 --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/TcpDualScannerService.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; +using AxiOmron.PcbCheck.Utils; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供两把 TCP 扫码枪的触发、读取与结果汇总能力。 +/// +public sealed class TcpDualScannerService : IScannerService +{ + private readonly ScannerOptions _options; + private readonly IAppLogger _logger; + private readonly SemaphoreSlim _ioLock = new(1, 1); + + /// + /// 初始化双 TCP 扫码枪服务。 + /// + /// 应用根配置。 + /// 日志记录器。 + public TcpDualScannerService(AppConfig config, IAppLogger logger) + { + ArgumentNullException.ThrowIfNull(config); + _options = config.Scanner; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 触发两把扫码枪扫码,并按双扫码规则返回汇总结果。 + /// + /// 取消令牌。取消后终止等待并向调用方传播取消。 + /// 双扫码汇总结果。 + public async Task TriggerScanAsync(CancellationToken cancellationToken) + { + await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ScanOperationResult first; + ScanOperationResult second; + if (_options.TriggerInParallel) + { + Task firstTask = TriggerSingleScannerAsync(_options.Scanner1, cancellationToken); + Task secondTask = TriggerSingleScannerAsync(_options.Scanner2, cancellationToken); + await Task.WhenAll(firstTask, secondTask).ConfigureAwait(false); + first = await firstTask.ConfigureAwait(false); + second = await secondTask.ConfigureAwait(false); + } + else + { + first = await TriggerSingleScannerAsync(_options.Scanner1, cancellationToken).ConfigureAwait(false); + second = await TriggerSingleScannerAsync(_options.Scanner2, cancellationToken).ConfigureAwait(false); + } + + ScanOperationResult merged = DualScannerResultMerger.Merge(first, second); + if (merged.IsSuccess && !string.IsNullOrWhiteSpace(merged.ErrorMessage)) + { + _logger.LogWarning(merged.ErrorMessage, true); + } + + return merged; + } + finally + { + _ioLock.Release(); + } + } + + /// + /// 测试两把扫码枪连接。任一失败均返回失败。 + /// + /// 取消令牌。取消后终止连接测试。 + /// 两把扫码枪均可连接返回 ;否则返回 。 + public async Task TestConnectionAsync(CancellationToken cancellationToken) + { + Task first = TestEndpointAsync(_options.Scanner1, cancellationToken); + Task second = TestEndpointAsync(_options.Scanner2, cancellationToken); + bool[] results = await Task.WhenAll(first, second).ConfigureAwait(false); + return results.All(static result => result); + } + + /// + /// TCP 扫码枪采用短连接触发,无需保持重连状态。 + /// + /// 取消令牌。 + /// 表示操作完成的任务。 + public async Task ForceReconnectAsync(CancellationToken cancellationToken) + { + _ = await TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// 触发单把扫码枪并读取返回。 + /// + /// 扫码枪连接参数。 + /// 取消令牌。 + /// 单把扫码枪结果。 + private async Task TriggerSingleScannerAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + using var client = new TcpClient(); + await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false); + await using NetworkStream stream = client.GetStream(); + + byte[] commandBytes = Encoding.ASCII.GetBytes(StringEscapeHelper.Unescape(_options.TriggerCommand)); + await stream.WriteAsync(commandBytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + + string rawMessage = await ReadResponseAsync(stream, _options.ReadTimeoutMs, cancellationToken).ConfigureAwait(false); + string barcode = BarcodeCleaner.Clean(rawMessage); + if (string.IsNullOrEmpty(barcode)) + { + return new ScanOperationResult + { + IsSuccess = false, + DeviceConnected = true, + RawMessage = rawMessage, + ErrorMessage = $"{endpoint.Name} 返回空字符串或仅包含控制字符。", + DurationMs = stopwatch.ElapsedMilliseconds + }; + } + + return new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = true, + Barcode = barcode, + RawMessage = rawMessage, + DurationMs = stopwatch.ElapsedMilliseconds + }; + } + catch (TimeoutException ex) + { + _logger.LogWarning(ex, "{ScannerName} 扫码等待超时", true, endpoint.Name); + return new ScanOperationResult + { + IsSuccess = false, + DeviceConnected = true, + ErrorMessage = $"{endpoint.Name} 扫码超时。", + DurationMs = stopwatch.ElapsedMilliseconds + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{ScannerName} 扫码失败", true, endpoint.Name); + return new ScanOperationResult + { + IsSuccess = false, + IsSystemError = true, + DeviceConnected = false, + ErrorMessage = $"{endpoint.Name} 通信失败: {ex.Message}", + DurationMs = stopwatch.ElapsedMilliseconds + }; + } + } + + /// + /// 测试单个扫码枪端点是否可连接。 + /// + /// 扫码枪连接参数。 + /// 取消令牌。 + /// 可连接返回 。 + private async Task TestEndpointAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(); + await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false); + return client.Connected; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "{ScannerName} TCP 连接测试失败", false, endpoint.Name); + return false; + } + } + + /// + /// 按配置超时建立 TCP 连接。 + /// + /// TCP 客户端。 + /// 扫码枪连接参数。 + /// 取消令牌。 + /// 表示连接完成的任务。 + private async Task ConnectAsync(TcpClient client, TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs)); + await client.ConnectAsync(endpoint.Host, endpoint.Port, timeoutCts.Token).ConfigureAwait(false); + } + + /// + /// 读取扫码枪响应,直到遇到终止符、读到首批数据或超时。 + /// + /// 网络流。 + /// 读取超时,单位为毫秒。 + /// 取消令牌。 + /// 原始响应文本。 + private async Task ReadResponseAsync(NetworkStream stream, int timeoutMs, CancellationToken cancellationToken) + { + string terminator = StringEscapeHelper.Unescape(_options.ResponseTerminator); + var buffer = new byte[1024]; + var builder = new StringBuilder(); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!stream.DataAvailable) + { + await Task.Delay(20, cancellationToken).ConfigureAwait(false); + continue; + } + + int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read <= 0) + { + break; + } + + builder.Append(Encoding.ASCII.GetString(buffer, 0, read)); + if (string.IsNullOrEmpty(terminator) || builder.ToString().Contains(terminator, StringComparison.Ordinal)) + { + return builder.ToString(); + } + } + + if (builder.Length > 0 && string.IsNullOrEmpty(terminator)) + { + return builder.ToString(); + } + + throw new TimeoutException("扫码枪在规定时间内未返回完整报文。"); + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index 6786b0b..f34f8a7 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -12,7 +12,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { private readonly IPlcService _plcService; private readonly IScannerService _scannerService; - private readonly ISftpLookupService _sftpLookupService; + private readonly IFileCheckService _fileCheckService; private readonly IAndonService _andonService; private readonly IBoardRecordRepository _boardRecordRepository; private readonly IAppStateStore _stateStore; @@ -40,7 +40,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS /// /// PLC 服务。 /// 扫码枪服务。 - /// SFTP 校验服务。 + /// 文件校验服务。 /// 安灯服务。 /// 运行态存储。 /// 应用配置。 @@ -48,12 +48,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS public WorkflowHostedService( IPlcService plcService, IScannerService scannerService, - ISftpLookupService sftpLookupService, + IFileCheckService fileCheckService, IAndonService andonService, IAppStateStore stateStore, AppConfig config, IAppLogger appLogger) - : this(plcService, scannerService, sftpLookupService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository()) + : this(plcService, scannerService, fileCheckService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository()) { } @@ -62,7 +62,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS /// /// PLC 服务。 /// 扫码枪服务。 - /// SFTP 校验服务。 + /// 文件校验服务。 /// 安灯服务。 /// 运行态存储。 /// 应用配置。 @@ -71,7 +71,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS public WorkflowHostedService( IPlcService plcService, IScannerService scannerService, - ISftpLookupService sftpLookupService, + IFileCheckService fileCheckService, IAndonService andonService, IAppStateStore stateStore, AppConfig config, @@ -80,7 +80,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { _plcService = plcService ?? throw new ArgumentNullException(nameof(plcService)); _scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService)); - _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService)); + _fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService)); _andonService = andonService ?? throw new ArgumentNullException(nameof(andonService)); _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _config = config ?? throw new ArgumentNullException(nameof(config)); @@ -98,7 +98,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true); await RestoreBoardRecordsAsync(stoppingToken).ConfigureAwait(false); await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false); - await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false); + await ProbeFileCheckOnStartupAsync(stoppingToken).ConfigureAwait(false); await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false); PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位"); @@ -171,15 +171,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS } /// - /// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。 + /// 在应用启动后执行一次文件校验通道探活,仅更新状态与日志,不阻断程序运行。 /// /// 取消令牌。若应用正在关闭,则中止本次探活。 /// 表示探活完成的任务。 - public async Task ProbeSftpOnStartupAsync(CancellationToken cancellationToken) + public async Task ProbeFileCheckOnStartupAsync(CancellationToken cancellationToken) { try { - SftpConnectionTestOutcome outcome = await _sftpLookupService + FileCheckTestOutcome outcome = await _fileCheckService .TestConnectionAsync(null, cancellationToken) .ConfigureAwait(false); @@ -196,20 +196,20 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS if (outcome.IsSuccess) { - _appLogger.LogInformation($"启动时 SFTP 探活成功: {outcome.StatusMessage}", true); + _appLogger.LogInformation($"启动时文件校验通道探活成功: {outcome.StatusMessage}", true); } else { - _appLogger.LogWarning($"启动时 SFTP 探活失败: {outcome.StatusMessage}", true); + _appLogger.LogWarning($"启动时文件校验通道探活失败: {outcome.StatusMessage}", true); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - _appLogger.LogInformation("启动时 SFTP 探活已取消。"); + _appLogger.LogInformation("启动时文件校验通道探活已取消。"); } catch (Exception ex) { - _appLogger.LogError(ex, "启动时 SFTP 探活异常"); + _appLogger.LogError(ex, "启动时文件校验通道探活异常"); UpdateSnapshot(snapshot => snapshot.SftpStatus = "异常"); } } @@ -582,7 +582,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS if (!string.IsNullOrEmpty(barcode)) { - await ExecuteSftpFlowAsync(barcode, cancellationToken).ConfigureAwait(false); + await ExecuteFileCheckFlowAsync(barcode, cancellationToken).ConfigureAwait(false); } if (IsFaultLatched()) @@ -605,95 +605,79 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS /// 扫码成功则返回条码;否则返回空字符串。 private async Task ExecuteScanFlowAsync(CancellationToken cancellationToken) { - for (var attempt = 1; attempt <= _config.Scanner.MaxScanAttempts; attempt++) + lock (_stateSyncRoot) { - lock (_stateSyncRoot) - { - _scanTryCount = attempt; - } + _scanTryCount = 1; + } - await ApplyProcessStateAsync( - WorkflowState.Scanning, - $"正在执行第 {attempt} 次扫码", - _ => { }, - cancellationToken).ConfigureAwait(false); + await ApplyProcessStateAsync( + WorkflowState.Scanning, + "正在触发两把 TCP 扫码枪", + _ => { }, + cancellationToken).ConfigureAwait(false); - var scanResult = await _scannerService.TriggerScanAsync(cancellationToken).ConfigureAwait(false); - UpdateSnapshot(snapshot => - { - snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线"; - }); + var scanResult = await _scannerService.TriggerScanAsync(cancellationToken).ConfigureAwait(false); + UpdateSnapshot(snapshot => + { + snapshot.ScannerStatus = scanResult.DeviceConnected ? "在线" : "离线"; + }); - if (scanResult.IsSystemError) - { - await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.ScannerFault, scanResult.ErrorMessage, cancellationToken).ConfigureAwait(false); - return string.Empty; - } - - if (scanResult.IsSuccess) - { - lock (_stateSyncRoot) - { - _currentBarcode = scanResult.Barcode; - } - - await ApplyProcessStateAsync( - WorkflowState.CheckingSftp, - $"扫码成功: {scanResult.Barcode}", - _ => { }, - cancellationToken).ConfigureAwait(false); - _appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true); - return scanResult.Barcode; - } - - _appLogger.LogWarning($"第 {attempt} 次扫码失败: {scanResult.ErrorMessage}", true); - if (attempt < _config.Scanner.MaxScanAttempts) - { - await ApplyProcessStateAsync( - WorkflowState.ScanRetrying, - $"扫码失败,等待第 {attempt + 1} 次重试", - _ => { }, - cancellationToken).ConfigureAwait(false); - continue; - } - - lock (_stateSyncRoot) - { - _resultCode = WorkflowResultCode.NgReleased; - } - - await ApplyProcessStateAsync( - WorkflowState.ScanFailedReleased, - "扫码连续失败 3 次,进入报警放行", - _ => { }, - cancellationToken).ConfigureAwait(false); - - await RaiseAlarmIfNeededAsync( - new AndonAlarmRequest - { - AlarmType = "ScanFailed", - AlarmCode = (ushort)AlarmCode.ScanFailed, - AlarmMessage = "扫码连续失败 3 次", - Barcode = string.Empty, - TriggeredAt = DateTimeOffset.Now - }, - _config.Andon.EnableScanFailAlarm, - cancellationToken).ConfigureAwait(false); + if (scanResult.IsSystemError) + { + await EnterFaultedAsync(WorkflowResultCode.Fault, AlarmCode.ScannerFault, scanResult.ErrorMessage, cancellationToken).ConfigureAwait(false); return string.Empty; } + if (scanResult.IsSuccess) + { + lock (_stateSyncRoot) + { + _currentBarcode = scanResult.Barcode; + } + + await ApplyProcessStateAsync( + WorkflowState.CheckingSftp, + $"双扫码汇总成功: {scanResult.Barcode}", + _ => { }, + cancellationToken).ConfigureAwait(false); + _appLogger.LogInformation($"双扫码汇总成功,条码={scanResult.Barcode},明细={scanResult.RawMessage}", true); + return scanResult.Barcode; + } + + lock (_stateSyncRoot) + { + _resultCode = WorkflowResultCode.NgReleased; + } + + await ApplyProcessStateAsync( + WorkflowState.ScanFailedReleased, + "两把扫码枪均失败,进入报警放行", + _ => { }, + cancellationToken).ConfigureAwait(false); + + await RaiseAlarmIfNeededAsync( + new AndonAlarmRequest + { + AlarmType = "ScanFailed", + AlarmCode = (ushort)AlarmCode.ScanFailed, + AlarmMessage = "两把扫码枪均扫码失败", + Barcode = string.Empty, + TriggeredAt = DateTimeOffset.Now + }, + _config.Andon.EnableScanFailAlarm, + cancellationToken).ConfigureAwait(false); return string.Empty; } /// - /// 执行 SFTP 文件校验流程。 + /// 执行文件校验流程。 /// /// 条码内容。 /// 取消令牌。 /// 表示流程完成的任务。 - private async Task ExecuteSftpFlowAsync(string barcode, CancellationToken cancellationToken) + private async Task ExecuteFileCheckFlowAsync(string barcode, CancellationToken cancellationToken) { - for (var attempt = 0; attempt <= _config.Sftp.MaxRetryCount; attempt++) + for (var attempt = 0; attempt <= _config.FileCheck.MaxRetryCount; attempt++) { lock (_stateSyncRoot) { @@ -702,11 +686,11 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await ApplyProcessStateAsync( WorkflowState.CheckingSftp, - $"正在执行第 {_sftpTryCount} 次 SFTP 校验", + $"正在执行第 {_sftpTryCount} 次文件校验", _ => { }, cancellationToken).ConfigureAwait(false); - var outcome = await _sftpLookupService.CheckFileAsync(barcode, cancellationToken).ConfigureAwait(false); + var outcome = await _fileCheckService.CheckFileAsync(barcode, cancellationToken).ConfigureAwait(false); UpdateSnapshot(snapshot => { snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证"); @@ -724,7 +708,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS $"文件已找到: {outcome.MatchedFilePath}", _ => { }, cancellationToken).ConfigureAwait(false); - _appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true); + _appLogger.LogInformation($"文件校验命中成功: {outcome.MatchedFilePath}", true); return; } @@ -740,15 +724,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS return; } - if (attempt < _config.Sftp.MaxRetryCount) + if (attempt < _config.FileCheck.MaxRetryCount) { - _appLogger.LogWarning($"第 {_sftpTryCount} 次 SFTP 未命中: {outcome.ErrorMessage}", true); + _appLogger.LogWarning($"第 {_sftpTryCount} 次文件校验未命中: {outcome.ErrorMessage}", true); await ApplyProcessStateAsync( WorkflowState.WaitingSftpRetry, - $"文件未命中,{_config.Sftp.RetryIntervalSeconds} 秒后重试", + $"文件未命中,{_config.FileCheck.RetryIntervalSeconds} 秒后重试", _ => { }, cancellationToken).ConfigureAwait(false); - await Task.Delay(TimeSpan.FromSeconds(_config.Sftp.RetryIntervalSeconds), cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(_config.FileCheck.RetryIntervalSeconds), cancellationToken).ConfigureAwait(false); continue; } @@ -762,7 +746,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS "文件未找到超时,按规则放行", _ => { }, cancellationToken).ConfigureAwait(false); - _appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true); + _appLogger.LogWarning($"文件校验未命中达到上限,条码={barcode}", true); if (_config.Andon.EnableFileNotFoundAlarm) { diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index d9e8975..e5a1bd2 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -154,9 +154,9 @@ public interface IScannerService } /// -/// 定义 SFTP 校验能力。 +/// 定义统一文件校验能力。 /// -public interface ISftpLookupService +public interface IFileCheckService { /// /// 按条码检查目标文件是否存在。 @@ -164,15 +164,43 @@ public interface ISftpLookupService /// 条码内容。 /// 取消令牌。 /// 文件校验结果。 - Task CheckFileAsync(string barcode, CancellationToken cancellationToken); + Task CheckFileAsync(string barcode, CancellationToken cancellationToken); /// - /// 测试当前或指定配置下的 SFTP 连接可用性。 + /// 测试当前或指定配置下的文件校验通道可用性。 /// - /// 待测试的 SFTP 配置;传入 时使用当前应用配置。 + /// 待测试的根配置;传入 时使用当前应用配置。 /// 取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。 - /// 连接测试结果。 - Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken); + /// 通道测试结果。 + Task TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken); +} + +/// +/// 定义单个文件校验通道提供程序。 +/// +public interface IFileCheckProvider +{ + /// + /// 获取当前提供程序对应的通道类型。 + /// + FileCheckProvider Provider { get; } + + /// + /// 按条码检查目标文件是否存在。 + /// + /// 条码内容。 + /// 根配置对象。 + /// 取消令牌。 + /// 文件校验结果。 + Task CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken); + + /// + /// 测试当前通道配置是否可用。 + /// + /// 根配置对象。 + /// 取消令牌。 + /// 通道测试结果。 + Task TestConnectionAsync(AppConfig config, CancellationToken cancellationToken); } /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs index c7566da..3f7d94b 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs @@ -111,7 +111,7 @@ public partial class MainWindowViewModel : ObservableObject private string _scannerStatus = "未验证"; /// - /// 获取或设置 SFTP 状态文本。 + /// 获取或设置文件校验通道状态文本。 /// [ObservableProperty] private string _sftpStatus = "未验证"; diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs index c7b4781..332ec42 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs @@ -4,6 +4,7 @@ using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Models; using System.Collections.ObjectModel; +using System.Collections.Generic; namespace AxiOmron.PcbCheck.ViewModels; @@ -13,7 +14,7 @@ namespace AxiOmron.PcbCheck.ViewModels; public partial class SystemSettingViewModel : ObservableObject { private readonly IAppConfigService _appConfigService; - private readonly ISftpLookupService _sftpLookupService; + private readonly IFileCheckService _fileCheckService; private readonly IAppStateStore _appStateStore; private readonly IDispatcherService _dispatcherService; @@ -21,22 +22,23 @@ public partial class SystemSettingViewModel : ObservableObject /// 初始化系统设置视图模型。 /// /// 配置读写服务。 - /// SFTP 连接测试服务。 + /// 文件校验通道测试服务。 /// 运行态快照存储。 /// UI 线程调度服务。 public SystemSettingViewModel( IAppConfigService appConfigService, - ISftpLookupService sftpLookupService, + IFileCheckService fileCheckService, IAppStateStore appStateStore, IDispatcherService dispatcherService) { _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); - _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService)); + _fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService)); _appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore)); _dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService)); EditableConfig = _appConfigService.Load(); ConfigPath = _appConfigService.GetConfigPath(); PlcMonitorItems = new ObservableCollection(); + FileCheckProviders = Enum.GetValues(); ApplySnapshot(_appStateStore.GetSnapshot()); _appStateStore.SnapshotChanged += OnSnapshotChanged; StatusMessage = "已加载配置。"; @@ -47,6 +49,11 @@ public partial class SystemSettingViewModel : ObservableObject /// public ObservableCollection PlcMonitorItems { get; } + /// + /// 获取文件校验通道可选项。 + /// + public IReadOnlyList FileCheckProviders { get; } + /// /// 获取或设置可编辑配置对象。 /// @@ -66,11 +73,11 @@ public partial class SystemSettingViewModel : ObservableObject private string _configPath = string.Empty; /// - /// 获取或设置当前是否正在测试 SFTP 连接。 + /// 获取或设置当前是否正在测试文件校验通道。 /// - [NotifyCanExecuteChangedFor(nameof(TestSftpConnectionCommand))] + [NotifyCanExecuteChangedFor(nameof(TestFileCheckConnectionCommand))] [ObservableProperty] - private bool _isTestingSftpConnection; + private bool _isTestingFileCheckConnection; /// /// 保存当前配置。 @@ -109,32 +116,32 @@ public partial class SystemSettingViewModel : ObservableObject } /// - /// 测试当前界面配置下的 SFTP 连接。 + /// 测试当前界面配置下的文件校验通道。 /// /// 表示测试完成的任务。 - [RelayCommand(CanExecute = nameof(CanTestSftpConnection))] - private async Task TestSftpConnectionAsync() + [RelayCommand(CanExecute = nameof(CanTestFileCheckConnection))] + private async Task TestFileCheckConnectionAsync() { - IsTestingSftpConnection = true; - StatusMessage = "正在测试 SFTP 连接..."; + IsTestingFileCheckConnection = true; + StatusMessage = "正在测试文件校验通道..."; try { - SftpConnectionTestOutcome outcome = await _sftpLookupService - .TestConnectionAsync(EditableConfig.Sftp, CancellationToken.None); + FileCheckTestOutcome outcome = await _fileCheckService + .TestConnectionAsync(EditableConfig, CancellationToken.None); StatusMessage = outcome.StatusMessage; } finally { - IsTestingSftpConnection = false; + IsTestingFileCheckConnection = false; } } /// - /// 判断当前是否允许执行 SFTP 测试连接。 + /// 判断当前是否允许执行文件校验通道测试。 /// - private bool CanTestSftpConnection => !IsTestingSftpConnection; + private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection; /// /// 将运行态快照中的 PLC 监控数据同步到当前视图模型。 diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml index ace5bad..bec7176 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml @@ -1,1132 +1,1132 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +