feat: 初始化 PCB 检测 WPF 应用程序

* 创建 AxiOmron.PcbCheck 项目主框架及解决方案
* 添加 Dashboard 和系统设置页面
* 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务
* 集成 Andon 报警、工作流托管服务与日志配置
* 补充项目文档和 UI 设计规范
This commit is contained in:
2026-04-17 10:43:51 +08:00
parent 660ee99442
commit 49f113dcf3
46 changed files with 8042 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(printf '\\\\n---\\\\n')",
"Bash(dotnet new:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet build:*)",
"Bash(dotnet add:*)",
"Bash(powershell -NoProfile -Command \"[Reflection.Assembly]::LoadFrom\\('C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll'\\).GetTypes\\(\\) | Where-Object { $_.Name -like '*Modbus*' -or $_.FullName -like '*Modbus*' } | Select-Object -ExpandProperty FullName\")",
"Bash(powershell -NoProfile -Command '[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\).GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\).GetMethods\\(\\) | Where-Object { $_.Name -in @\\(\"Connect\",\"ReadDiscrete\",\"Write\",\"ReadUInt16\"\\) } | ForEach-Object { $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::DeclaredOnly\\) | ForEach-Object { $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetInterfaces\\(\\) | ForEach-Object { $_.FullName }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); \"BASE: $\\($type.BaseType.FullName\\)\"; $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public\\) | Where-Object { $_.Name -match \"Connect|Close|Dispose|Disconnect|Open|Start|Stop|Release\" } | ForEach-Object { $_.DeclaringType.FullName + \" :: \" + $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetConstructors\\(\\) | ForEach-Object { $_.ToString\\(\\) }')",
"Read(//d D:/Dev/Codes/MFD_Solution/Axi_Omron/src/**)",
"Bash(dotnet restore:*)"
]
}
}

58
.editorconfig Normal file
View File

@@ -0,0 +1,58 @@
[*.{c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cu,cuh,cxx,cxxm,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,mpp,mq4,mq5,mqh,mxx,tpp,usf,ush}]
indent_style = tab
indent_size = tab
tab_width = 4
[*.{asax,ascx,aspx,axaml,cshtml,htm,html,master,paml,razor,skin,vb,xaml,xamlx,xoml}]
indent_style = space
indent_size = 4
tab_width = 4
[*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,jsproj,lsproj,njsproj,nuspec,proj,props,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}]
indent_style = space
indent_size = 2
tab_width = 2
[*.cs]
indent_style = space
indent_size = 4
tab_width = 4
resharper_csharp_keep_multiple_properties_on_single_line = false
resharper_csharp_keep_multiple_accessors_on_single_line = false
[*]
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
dotnet_style_qualification_for_event = false:suggestion
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
# ReSharper inspection severities
resharper_arrange_redundant_parentheses_highlighting = hint
resharper_arrange_this_qualifier_highlighting = hint
resharper_arrange_type_member_modifiers_highlighting = hint
resharper_arrange_type_modifiers_highlighting = hint
resharper_built_in_type_reference_style_for_member_access_highlighting = hint
resharper_built_in_type_reference_style_highlighting = hint
resharper_redundant_base_qualifier_highlighting = warning
resharper_suggest_var_or_type_built_in_types_highlighting = hint
resharper_suggest_var_or_type_elsewhere_highlighting = hint
resharper_suggest_var_or_type_simple_types_highlighting = hint
# ReSharper properties
resharper_place_accessorholder_attribute_on_same_line = false
resharper_place_accessor_attribute_on_same_line = false
resharper_place_field_attribute_on_same_line = false

19
.gitattributes vendored Normal file
View File

@@ -0,0 +1,19 @@
# Normalize all text files to LF
# Normalize common .NET text files to CRLF
*.cs text eol=crlf
*.xaml text eol=crlf
*.csproj text eol=crlf
*.sln text eol=crlf
*.config text eol=crlf
*.json text eol=crlf
*.md text eol=crlf
*.pubxml text eol=crlf
*.targets text eol=crlf
*.props text eol=crlf
*.tt text eol=crlf
# Default: leave unspecified types untouched
* -text
# Do not normalize binary DLLs
*.dll binary

64
.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# .NET Core
bin/
obj/
*.user
*.userosscache
*.suo
*.userprefs
# Visual Studio
.vs/
.vscode/
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio cache/options directory
.vs/
# SQLite database files
*.db
*.db-shm
*.db-wal
# Log files
logs/
*.log
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# NuGet
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
*.nuget.props
*.nuget.targets
.packages/
.dotnet/
.worktrees/
参考程序/*
.superpowers/*

248
AGENTS.md Normal file
View File

@@ -0,0 +1,248 @@
# AGENTS C# WPF 上位机项目初始化规范
本文件为在本仓库中创建和维护 **新的 C# WPF 上位机项目** 的智能体提供统一操作规范,作用域覆盖整个仓库。
若某个子目录存在更深层级的 `AGENTS.md`,则以更具体的规则为准。
## 适用说明
- 本仓库中的历史项目、样例项目、参考项目仅用于借鉴工程结构与协作方式,不视为新项目的默认业务实现。
- 新项目初始化时,优先采用 **.NET 8 + WPF + MVVM Toolkit + Generic Host/DI + NLog** 这一默认技术基线。
- 不要直接复制参考项目中的业务命名、设备协议、数据库模型、接口定义、配置项或目录命名,除非用户明确要求。
- 若用户仅要求“初始化项目”或“生成上位机骨架”,默认理解为生成一个结构清晰、可扩展、适合工业桌面应用的 WPF 工程,而不是复制旧项目的业务代码。
## 推荐技术基线
- 平台Windows
- SDK.NET 8 SDK
- UIWPF
- 项目文件SDK-style `.csproj`
- 目标框架:`net8.0-windows`
- 语言特性:`<Nullable>enable</Nullable>``<ImplicitUsings>enable</ImplicitUsings>`
- MVVM`CommunityToolkit.Mvvm`
- 宿主与依赖注入:`Microsoft.Extensions.Hosting`
- 日志:`NLog` + `NLog.Extensions.Logging`
- 配置:`appConfig.json` + `appConfig.{Environment}.json`
- 开发期敏感配置:`UserSecrets` 或环境变量
- 测试:优先单独测试项目,推荐 xUnitUI 难测逻辑应下沉到 ViewModel 或 Service
## 新项目初始化骨架
若无额外约束,推荐使用如下仓库结构:
```text
src/
<ProjectName>/
<ProjectName>.csproj
App.xaml
App.xaml.cs
MainWindow.xaml
MainWindow.xaml.cs
appConfig.json
appConfig.Development.json
NLog.config
Assets/
Models/
ViewModels/
Views/
Pages/
UserControls/
Services/
Interfaces/
Implementations/
Modules/
Utils/
tests/
<ProjectName>.Tests/
```
约束如下:
- `App.xaml` / `App.xaml.cs` 负责应用启动、Host 创建、全局异常处理、配置与日志装配。
- `MainWindow.xaml` / `.cs` 只负责主窗口装配,不承载核心业务逻辑。
- `ViewModels/` 保存可绑定状态、命令和页面协调逻辑。
- `Views/Pages/``Views/UserControls/` 保存页面和可复用视图组件。
- `Services/Interfaces/``Services/Implementations/` 用于硬件通信、文件处理、MES/PLC/条码/图像处理等业务服务。
- `Modules/` 用于较独立的能力模块,例如数据库、设备协议封装、审计上传、报表导出等。
- `Utils/` 仅保留轻量、通用、无业务语义的辅助工具;不要把主要业务逻辑塞入工具类。
- `tests/` 必须与应用项目分离,避免把测试代码混入正式程序集。
若仓库当前只有单个应用项目,也可暂时不建 `src/` 目录,直接在仓库根下放置 `<ProjectName>/`;但若无明确要求,优先采用 `src/ + tests/` 结构。
## 项目初始化最低要求
新建项目时,至少保证以下内容一次到位:
- WPF 应用可以成功还原、编译、启动。
- `App.xaml.cs` 中建立统一的 Host/DI 启动入口。
- 主窗口与主要 ViewModel 通过依赖注入创建。
- 日志系统已接入,且启动阶段异常可落日志。
- 配置文件支持环境分层加载。
- 输出目录包含 `appConfig.json``appConfig.Development.json``NLog.config`
- 为未来扩展预留 `Services``ViewModels``Views``Utils``Modules` 目录。
- 若涉及设备通信、轮询、文件监控、批处理等后台任务,必须预先考虑取消、限流、异常记录与 UI 线程切换。
## 构建 / 还原 / 运行 / 发布
以下命令中的 `<ProjectName>` 代表新建 WPF 项目名:
- 创建解决方案:`dotnet new sln -n <SolutionName>`
- 创建 WPF 项目:`dotnet new wpf -n <ProjectName> --framework net8.0`
- 还原:`dotnet restore src/<ProjectName>/<ProjectName>.csproj`
- 调试构建:`dotnet build src/<ProjectName>/<ProjectName>.csproj -c Debug`
- 发布构建:`dotnet build src/<ProjectName>/<ProjectName>.csproj -c Release`
- 运行UI`dotnet run --project src/<ProjectName>/<ProjectName>.csproj -c Debug`
- 发布示例:`dotnet publish src/<ProjectName>/<ProjectName>.csproj -c Release -r win-x64 --self-contained false`
初始化或修改 `.csproj` 时,至少保持以下属性:
- `<UseWPF>true</UseWPF>`
- `<TargetFramework>net8.0-windows</TargetFramework>`
- `<Nullable>enable</Nullable>`
- `<ImplicitUsings>enable</ImplicitUsings>`
若项目包含配置、日志、资源或字典文件,需要同步设置 `CopyToOutputDirectory`,确保运行目录完整。
## 测试 / 验证
- 新项目默认应预留独立测试项目:`tests/<ProjectName>.Tests/`
- 优先测试 ViewModel、Service、配置装配、解析逻辑、队列/调度逻辑,不要把核心逻辑压在难以测试的 code-behind 中。
- 若功能与 UI 强绑定,应先抽离为接口或服务,再进行单元测试或集成测试。
- 若暂未建立测试项目,至少执行一次构建验证,并手动完成关键 UI 流程检查。
- 单元测试示例:`dotnet test --filter FullyQualifiedName~命名空间.类名.方法名`
- 提交前最低要求:`dotnet build` 成功;涉及关键逻辑时应补充对应测试。
## Lint / 格式
- 若仓库尚未提供统一格式化工具或 `.editorconfig`,保持现有风格一致,不擅自引入新的格式化方案。
- 保持 using 简洁,移除未使用引用。
- 使用文件作用域命名空间,除非现有项目明确不采用。
- 避免只为“看起来更优雅”而大范围重排代码格式。
## XML 文档注释规则
- 所有 `public` / `internal` / `protected` 类、接口、记录、方法、属性必须有 **中文 XML 文档注释**
- 私有成员若逻辑不简单,或方法体超过 10 行,也应补充 XML 注释。
- 方法注释必须包含:
- `<summary>`
- 每个参数的 `<param>`
- 有返回值时的 `<returns>`
- 可能抛出异常时的 `<exception>`
- 异步方法名必须以 `Async` 结尾。
- 若方法接收 `CancellationToken`,需要在注释中写明取消行为及影响。
- 涉及命令、绑定、Dispatcher、后台线程、Task.Run、定时器、轮询、文件监控时注释必须明确线程模型和触发行为。
- 已有 XML 注释不得删除;修改实现后必须同步更新注释内容。
## 代码风格(通用 C#
- 命名空间使用文件作用域写法。
- 可读性优先:类型不明显时使用显式类型,明显时可使用 `var`
- 字段优先使用 `private readonly`,字段命名采用 `_camelCase`
- 常量使用 `const PascalCase`
- 参数校验只放在系统边界:用户输入、外部接口、文件内容、网络返回、硬件返回等位置。
- 不要为当前需求之外的场景做预留抽象。
- 不要新增只被调用一次的帮助类、工具类或接口。
- 不要为了兼容历史命名而保留无意义的中间层,确认无用即可删除。
## MVVM / WPF 实践
- 业务逻辑放在 ViewModel 或 Servicecode-behind 仅做视图装配、事件桥接、生命周期对接。
- 命令优先使用 `RelayCommand` / `AsyncRelayCommand`
- 可绑定属性优先使用 `[ObservableProperty]` 生成,不手写重复样板代码。
- 长耗时操作必须使用 `async/await`,不得阻塞 UI 线程。
- 后台线程不得直接更新绑定到 UI 的对象;涉及 UI 更新时必须切回 Dispatcher。
- 不要在 View 中直接 `new` 业务服务。
- 不要在 code-behind 中直接访问数据库、文件系统、MES、PLC、串口、相机或网络接口。
- 页面切换、对话框协调、状态共享应优先通过 ViewModel 或应用级服务完成。
- 若项目包含托盘、单实例、后台常驻、自动重连、设备轮询等能力,应将其建模为显式服务并通过 DI 管理生命周期。
## 宿主 / DI / 启动约定
-`App.xaml.cs` 中集中创建 `HostApplicationBuilder` 或等价 Host 对象。
- 所有服务、ViewModel、Window、Page 的注册集中管理,不要分散在多个随机文件中。
- 优先使用构造函数注入,不使用服务定位器模式。
- 应用启动顺序应清晰:配置加载 → 日志初始化 → Host 构建 → 服务注册 → 主窗口创建与显示。
- 若涉及全局异常捕获、未观察任务异常、UI 线程异常,应在启动阶段完成统一挂接。
- 若项目需要单实例约束,应采用明确、可维护的单实例方案,并保证启动顺序可追踪。
## 日志 / 异常 / 用户提示
- 注入 `ILogger<T>` 进行日志记录,优先使用结构化日志。
- 记录错误时使用包含异常对象的重载,例如:`_logger.LogError(ex, "消息 {上下文}", value)`
- 不要静默吞异常;至少按 Debug / Warning / Error 级别记录。
- 对用户可感知的失败,要同时提供用户友好提示与详细日志。
- 对启动失败、配置错误、设备离线、文件访问失败、网络错误等场景,应明确记录上下文。
- 对于可恢复错误,应在日志中记录恢复动作;对于不可恢复错误,应阻止继续执行并给出清晰提示。
## 配置与环境管理
- 配置文件默认采用:
- `appConfig.json`
- `appConfig.Development.json`
- 如有必要再增加 `appConfig.Production.json``appConfig.Local.json`
- 运行环境通过 `DOTNET_ENVIRONMENT``ASPNETCORE_ENVIRONMENT` 识别,默认 `Production`
- 不要硬编码密钥、口令、连接串、IP、账号。
- 开发期敏感配置优先使用 `UserSecrets` 或环境变量。
- 新增配置文件、资源文件、字典文件时,必须同步 `.csproj` 的输出复制规则。
- 读取配置时优先建立强类型 Options 或清晰的数据模型,不散落魔法字符串。
## 集合、并发与 I/O
- 文件处理优先使用流式 API`Directory.EnumerateFiles`,避免一次性加载大量文件。
- 批处理、轮询、文件监控、设备通信等后台任务需具备可取消性。
- 对共享状态使用明确的并发策略:`SemaphoreSlim``ConcurrentQueue``ConcurrentDictionary`、Channel 或串行队列。
- 不要锁住 UI 线程等待后台结果。
- 对缓冲区、日志列表、待处理队列要设置上限,避免内存无限增长。
- 所有后台任务都必须定义“谁创建、谁取消、谁记录异常、谁负责收尾”。
## 命名约定
- 类 / 结构 / 记录 / 枚举:`PascalCase`
- 接口:`I` 前缀 + `PascalCase`
- 方法 / 属性 / 事件:`PascalCase`
- 私有字段:`_camelCase`
- 局部变量 / 参数:`camelCase`
- 命令:`*Command`
- 异步方法:`*Async`
- 配置类型名应与配置节含义一致,不使用模糊命名如 `ConfigHelper``DataManager``CommonUtil`
## 新项目初始化完成的判定标准
一个新建 WPF 上位机项目,至少达到以下状态才可认为“初始化完成”:
- 可以成功 `restore``build``run`
- 主窗口可以正常显示
- Host / DI 已接入,主窗口和主 ViewModel 不依赖手工拼装
- 配置文件可被读取,环境分层生效
- 日志文件或日志输出可验证
- 核心目录结构已建立
- 至少保留一个可扩展的 Service、一个可扩展的 ViewModel、一个基础页面或主窗口示例
- 若已经接入设备/文件监听/后台任务,则具备最基本的取消与异常记录机制
## 贡献检查清单
- 是否遵循本文件规定的初始化骨架与技术基线。
- 是否保持业务逻辑不进入 code-behind。
- 是否为公开成员补齐中文 XML 注释。
- 是否已接入 Host/DI、配置和日志。
- 是否移除未使用 using、无意义抽象和一次性工具层。
- 是否避免硬编码敏感信息。
- 是否在涉及后台任务时明确线程切换、取消与异常处理。
- 是否为新增配置/资源设置输出复制规则。
- 是否在提交前至少执行一次 `dotnet build`
## 快速命令速查
- 新建解决方案:`dotnet new sln -n <SolutionName>`
- 新建 WPF 项目:`dotnet new wpf -n <ProjectName> --framework net8.0`
- 添加到解决方案:`dotnet sln add src/<ProjectName>/<ProjectName>.csproj`
- 还原:`dotnet restore src/<ProjectName>/<ProjectName>.csproj`
- 调试构建:`dotnet build src/<ProjectName>/<ProjectName>.csproj -c Debug`
- 发布构建:`dotnet build src/<ProjectName>/<ProjectName>.csproj -c Release`
- 运行调试:`dotnet run --project src/<ProjectName>/<ProjectName>.csproj -c Debug`
- 发布示例:`dotnet publish src/<ProjectName>/<ProjectName>.csproj -c Release -r win-x64 --self-contained false`
- 单测示例:`dotnet test --filter FullyQualifiedName~命名空间.类名.方法名`
## 额外说明
- 如果后续在某个新项目目录下生成了更具体的 `AGENTS.md`,应把该项目特有的设备协议、数据库约束、部署方式、配置项和 UI 行为写入子级文档,而不是污染仓库级规则。
- 仓库级 `AGENTS.md` 应保持“可复用、可初始化、不过度绑定具体业务”的定位。

2
AxiOmron.PcbCheck.slnx Normal file
View File

@@ -0,0 +1,2 @@
<Solution>
</Solution>

View File

@@ -0,0 +1,897 @@
# PCB 目检软件需求与方案设计
## 1. 文档目的
本文档用于整理当前 PCB 简易目检上位机的软件需求、业务流程、通信接口、异常处理和 Modbus TCP 点位设计,作为后续开发、联调和现场验收的依据。
当前版本仅覆盖以下能力:
- PLC 到位信号接入
- 串口触发扫码枪扫码
- SFTP 文件存在性校验
- PLC 放行信号回写
- HTTP 安灯报警接口调用
- 关键过程日志与状态记录
本文档不包含以下能力:
- 相机采图
- 图像算法判定
- 人工复判
- 多工位并行处理
- MES/WMS 深度集成
---
## 2. 系统范围与边界
### 2.1 系统角色
系统由以下四个外部对象和一个上位机应用组成:
1. **PLC**
- 通过 Modbus TCP 与上位机通信
- 提供 PCB 到位、复位、运行允许等状态
- 接收上位机放行、流程完成、故障/报警等结果信号
2. **扫码枪**
- 通过串口与上位机通信
- 接收上位机触发扫描指令
- 返回二维码内容或超时失败
3. **SFTP 服务器**
- 用于存放与 PCB 二维码 ID 对应的判定文件或放行文件
- 上位机根据扫码结果访问指定目录并查找目标文件
4. **安灯系统**
- 通过 HTTP 接口接收报警请求
- 用于扫码失败等异常场景的现场告警
5. **上位机软件**
- 负责流程编排、设备通信、状态机控制、日志记录、异常处理和 PLC 结果回写
### 2.2 运行约束
- 运行模式为**单件串行处理**
- 同一时刻只处理一块 PCB
- 上位机作为 **Modbus TCP Client**
- PLC 作为 **Modbus TCP Server**
- 扫码最多尝试 **3 次(含首次触发)**
- SFTP 采用 **1 次首次查询 + 最多 N 次重试** 的方式检查目标文件
- 扫码失败 3 次后仍 **放行**,同时触发安灯报警
- SFTP 文件最终未找到时仍 **放行**,默认仅记录日志,不强制安灯报警
- SFTP 连接失败、认证失败、目录配置错误等**系统级异常**不等同于“文件不存在”,默认进入系统故障,不自动放行
---
## 3. 需求整理
### 3.1 功能需求
#### 3.1.1 到位触发
- 当 PCB 到达指定工位后PLC 通过 Modbus TCP 点位通知上位机“PCB 已到位”
- 上位机在检测到到位信号后,启动本次单板处理流程
- 上位机应避免同一块板被重复触发处理
#### 3.1.2 扫码控制
- 上位机通过串口向扫码枪发送触发命令
- 扫码枪返回二维码字符串
- 如果单次扫码失败,上位机允许继续重试
- 当前版本最大尝试次数固定为 **3 次(含首次触发)**
#### 3.1.3 扫码失败处理
- 若连续 3 次扫码失败:
- 上位机调用 HTTP 安灯报警接口
- 上位机记录失败原因和报警结果
- 上位机仍向 PLC 发送放行信号
- 本次流程结果标记为“扫码失败放行”
#### 3.1.4 SFTP 文件校验
- 扫码成功后,上位机根据二维码 ID 到配置好的 SFTP 目录中查找对应文件
- 当前版本采用 **首次查询 1 次 + 文件未命中后最多重试 N 次** 的规则,`N` 为可配置项
- 若文件存在:
- 视为校验通过
- 上位机立即发送放行信号给 PLC
- 若文件不存在:
- 等待 X 秒后再次检查
- 达到重试上限后按“文件未找到超时处理”执行
#### 3.1.5 文件未找到超时处理
- 若达到 SFTP 查询重试上限后仍未找到对应文件:
- 上位机记录“文件未找到超时放行”
- 上位机仍向 PLC 发送放行信号
- 默认不强制调用安灯接口,但应保留后续扩展为可配置报警策略的设计空间
#### 3.1.6 二维码内容处理
- 二维码字符串在用于 SFTP 查询前,应先执行基础清洗:
- 去除首尾空白字符
- 去除回车换行等控制字符
- 清洗后若为空字符串,则视为本次扫码失败
- 当前版本不增加更复杂的正则或长度业务校验,避免引入未确认的规则
#### 3.1.7 PLC 交互
- 上位机需向 PLC 提供以下反馈能力:
- 忙碌状态
- 当前流程完成状态
- 放行信号
- 扫码成功/失败状态
- 文件存在/不存在状态
- 故障或报警状态
- 异常代码/结果代码
#### 3.1.8 日志与追溯
- 上位机应记录以下关键日志:
- 到位触发时间
- 扫码每次尝试结果
- 最终二维码内容
- SFTP 连接与查找结果
- HTTP 报警调用结果
- PLC 点位写入动作
- 本次流程最终结果
### 3.2 非功能需求
- 支持稳定连续运行
- 所有外部接口都应具备超时控制
- 所有异常都必须记录日志,不允许静默吞掉
- 同一时刻只能处理一块 PCB避免并发混板
- 配置项应集中管理,避免硬编码 IP、端口、账号、目录和重试参数
---
## 4. 推荐总体方案
### 4.1 总体架构
推荐采用**单工位串行状态机架构**。
该方案由一个主流程控制器串联以下模块:
- PLC 通信模块
- 扫码枪控制模块
- SFTP 校验模块
- 安灯报警模块
- 流程状态机模块
- 日志与结果记录模块
### 4.2 方案特点
- 适合当前“单件串行”场景
- 逻辑清晰,便于现场联调
- 点位数量可控,便于 PLC 程序实现
- 后续可在不推翻主体结构的前提下扩展更多结果码、报警策略和 UI 状态展示
### 4.3 不推荐的当前方案
当前阶段不建议直接采用多任务队列、多工位并行或由 PLC 承担大部分业务编排的重型架构,因为会显著增加联调复杂度,不符合“简单目检软件”的目标。
---
## 5. 软件模块设计
### 5.1 流程控制模块
负责:
- 接收 PLC 到位信号
- 驱动整个业务状态机流转
- 管理当前板的处理上下文
- 决定何时触发扫码、何时检查文件、何时报警、何时放行
建议职责边界:
- 一个流程控制器只负责一块当前 PCB
- 所有外部服务都由流程控制器统一调度
- 不允许 PLC、扫码、SFTP、报警模块彼此直接耦合调用
### 5.2 PLC 通信模块
负责:
- 周期性读取 PLC 点位
- 写入上位机结果点位
- 做好写脉冲、保持位和复位逻辑
- 管理断线重连和通信状态
建议接口:
- `ReadSignalsAsync()`:读取 PLC 输入状态
- `WriteHandshakeAsync()`:写入握手和结果点位
- `PulseReleaseAsync()`:发送放行脉冲
- `ResetResultBitsAsync()`:清理本次流程结果位
### 5.3 扫码枪控制模块
负责:
- 管理串口打开、关闭、重连
- 发送扫码命令
- 接收扫码返回值
- 执行单次扫码超时控制
- 将扫码结果以统一对象返回给流程控制器
建议输出统一结果:
- 是否成功
- 扫码内容
- 原始返回报文
- 失败原因
- 本次耗时
### 5.4 SFTP 校验模块
负责:
- 根据配置建立 SFTP 连接
- 按二维码 ID 查找目标文件
- 支持按目录、文件名模板或通配方式查找
- 执行重试、等待和超时控制
- 输出最终查找结果
建议支持的配置项:
- 服务器地址
- 端口
- 用户名
- 密码/密钥
- 根目录
- 文件名匹配规则
- 单次连接超时
- 查询等待秒数
- 最大重试次数
### 5.5 安灯报警模块
负责:
- 调用 HTTP 安灯接口
- 发送报警编码、报警内容、工位号、二维码等参数
- 处理响应结果并记录日志
建议支持的配置项:
- 接口 URL
- 请求方法
- 请求头
- 超时时间
- 报警编码
- 工位名称
- 是否启用扫码失败报警
- 是否启用文件未找到报警(预留)
### 5.6 日志与结果记录模块
负责:
- 写入运行日志
- 保存每板处理结果摘要
- 输出 UI 可显示的当前状态、结果和错误信息
建议记录字段:
- 触发时间
- 完成时间
- 条码内容
- 扫码次数
- SFTP 重试次数
- 最终结果代码
- 最终结果描述
- 是否调用安灯
- PLC 放行发送时间
---
## 6. 业务状态机设计
### 6.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 连接异常等
### 6.2 状态流转
#### 正常流转
`Idle -> Triggered -> Scanning -> CheckingSftp -> SftpPassed -> Releasing -> Completed -> Idle`
#### 扫码失败放行流转
`Idle -> Triggered -> Scanning -> ScanRetrying -> Scanning -> ScanRetrying -> Scanning -> ScanFailedReleased -> Releasing -> Completed -> Idle`
#### 文件未找到超时放行流转
`Idle -> Triggered -> Scanning -> CheckingSftp -> WaitingSftpRetry -> CheckingSftp -> ... -> SftpTimeoutReleased -> Releasing -> Completed -> Idle`
#### 系统故障流转
`任意流程态 -> Faulted -> 人工复位/PLC复位 -> Idle`
### 6.3 状态机约束
- 只有 `Idle` 状态才允许接收新的 PCB 到位触发
- 系统进入 `Faulted` 后,不允许自动接收下一块板,必须在故障恢复后由人工复位或 PLC 复位解除
- 允许后台自动执行断线重连,但在 `Faulted` 未解除前不得恢复业务处理
- 每次开始新流程前必须清理上一板的过程结果位
- `Release` 动作必须具备防重复发送保护
### 6.4 启动前置条件
系统只有在以下条件同时满足时,才视为处于可接板的 `Idle` 状态:
- PLC 通信已建立
- `PlcReady = 1`
- `AutoMode = 1`
- `StationEnable = 1`
- 上位机不存在未解除的 `SystemFault`
若上述条件不满足,上位机仅保持监视,不启动新板流程。
---
## 7. 详细流程说明
### 7.1 主流程
1. 上位机轮询 PLC 到位点位
2. 当检测到“到位”且当前状态为 `Idle` 时:
- 记录开始时间
- 置 Busy 位
- 清理上次结果位
- 写入当前流程状态码
- 进入扫码流程
3. 扫码成功后,进入 SFTP 校验流程
4. 根据 SFTP 结果决定立即放行或超时后放行
5. 发送放行信号给 PLC
6. 置流程完成位,并将最终结果码保持为稳定值
7. 等待 PLC 应答或到位信号撤销
8. 回到空闲状态
### 7.2 扫码流程
1. 发送扫码枪触发指令
2. 等待扫码结果,单次扫码应设置超时
3. 对扫码结果进行基础清洗:去掉空白和控制字符
4. 若成功:
- 保存二维码内容
- 清除 `ScanNg`
-`ScanOk`
- 写入当前流程状态码
- 进入 SFTP 校验流程
5. 若失败:
- 累加尝试次数
- 记录日志
- 若未达到最大尝试次数,则进入下一轮扫码
6. 达到 3 次后仍失败:
- 清除 `ScanOk`
-`ScanNg`
- 调用安灯报警
- 写入结果代码“扫码失败放行”
- 进入放行流程
说明:
- `ScanNg` 表示**最终扫码失败**,不用于表示中间某一次扫码失败
- 当前版本“3 次”定义为**总共最多 3 次尝试**,不是“首次 1 次 + 额外重试 3 次”
### 7.3 SFTP 校验流程
1. 按配置建立 SFTP 连接
2. 根据二维码 ID 拼接目标文件名或查找规则
3. 立即执行首次查询
4. 若文件存在:
- 清除 `FileNotFound`
-`FileFound`
- 写入结果代码“文件存在放行”
- 进入放行流程
5. 若文件不存在:
- 记录当前查询未命中
- 若未达到重试上限,则等待 X 秒再次查询
- 若达到重试上限,则清除 `FileFound`、置 `FileNotFound`、写入结果代码“文件未找到超时放行”,进入放行流程
6. 若出现 SFTP 连接失败、认证失败、目录不存在等系统级异常:
-`SystemFault`
- 写入故障结果码
- 进入 `Faulted`
说明:
- 当前版本 `MaxRetryCount = N` 表示**首次未命中后的最多重试次数**
- 因此总查询次数为 **1 + N**
- `FileNotFound` 表示**最终未找到**,不用于表示中间某次查询未命中
### 7.4 放行流程
1. 上位机向 PLC 写入放行请求位 `ReleasePermit`
2. 推荐采用**脉冲方式**输出放行信号,默认脉冲时长建议为 **500ms**,可配置
3. 同时写入最终结果码、流程完成位和稳定的状态位
4. 若 PLC 提供 `PlcAckRelease`
- 上位机等待 PLC 应答
- 最长等待时间建议为 **2000ms**,超时则记录告警并自动清除放行脉冲
5. 若 PLC 不提供 `PlcAckRelease`
- 上位机保持 `ReleasePermit` 至配置脉冲时长结束后自动清除
6. `ProcessDone = 1` 时,表示 `ResultCode``AlarmCode``ScanTryCount``SftpTryCount` 均已为最终稳定值
---
## 8. Modbus TCP 通信设计
### 8.1 通信角色
- 上位机Modbus TCP Client
- PLCModbus TCP Server
- 轮询周期建议100ms ~ 300ms
- 布尔量优先使用位点位,结果码优先使用 Holding Register
### 8.2 点位设计原则
- 输入点和输出点职责分离
- 过程状态位与最终结果码同时保留
- 对 PLC 需要快速判断的信号,优先给单独布尔位
- 对上位机 UI 和日志需要详细表达的结果,使用数值结果码补充
- 放行信号使用脉冲,忙碌位和完成位可使用保持方式
- 本文档采用**PLC -> 上位机为 Discrete Input、上位机 -> PLC 为 Coil** 的常规表达方式;若现场 PLC 地址区定义不同,可在实施阶段做地址映射,不改变信号语义
### 8.3 PLC -> 上位机点位(建议)
以下点位由 PLC 提供,上位机读取:
| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | Discrete Input | 10001 | PlcReady | PLC -> PC | PLC 就绪,允许上位机参与流程 |
| 2 | Discrete Input | 10002 | PcbArrived | PLC -> PC | PCB 已到位,请求上位机处理 |
| 3 | Discrete Input | 10003 | PlcReset | PLC -> PC | PLC 请求上位机清状态/复位 |
| 4 | Discrete Input | 10004 | PlcAckRelease | PLC -> PC | PLC 已接收到放行信号 |
| 5 | Discrete Input | 10005 | AutoMode | PLC -> PC | 设备当前处于自动模式 |
| 6 | Discrete Input | 10006 | StationEnable | PLC -> PC | 当前工位使能 |
说明:
- `PcbArrived` 为本流程主触发点
- `PlcAckRelease` 为可选应答点,若 PLC 侧不需要可取消
- `PlcReset` 用于人工清故障、恢复空闲态或清除保持位
- `10007 ~ 10050` 预留给后续 PLC -> 上位机扩展信号
### 8.4 上位机 -> PLC 点位(建议)
以下点位由上位机写入PLC 读取:
| 序号 | 地址类型 | 地址 | 点位名称 | 方向 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | Coil | 00051 | PcOnline | PC -> PLC | 上位机在线心跳位 |
| 2 | Coil | 00052 | PcBusy | PC -> PLC | 上位机正在处理当前 PCB |
| 3 | Coil | 00053 | ScanOk | PC -> PLC | 本次最终扫码成功 |
| 4 | Coil | 00054 | ScanNg | PC -> PLC | 本次最终扫码失败 |
| 5 | Coil | 00055 | FileFound | PC -> PLC | 最终找到对应 SFTP 文件 |
| 6 | Coil | 00056 | FileNotFound | PC -> PLC | 到达重试上限后仍未找到文件 |
| 7 | Coil | 00057 | AlarmRaised | PC -> PLC | 本次流程已触发安灯报警 |
| 8 | Coil | 00058 | ReleasePermit | PC -> PLC | 放行脉冲信号 |
| 9 | Coil | 00059 | ProcessDone | PC -> PLC | 本次流程已结束,结果码稳定 |
| 10 | Coil | 00060 | SystemFault | PC -> PLC | 上位机系统故障 |
说明:
- `ScanOk/ScanNg` 互斥
- `FileFound/FileNotFound` 互斥
- `ReleasePermit` 采用脉冲输出,不建议长时间保持
- `ProcessDone = 1` 表示寄存器中的结果值已经稳定PLC 可以在此时读取 `ResultCode`
- `AlarmRaised` 建议保持到下一板开始或收到 `PlcReset` 后再清除
- `SystemFault` 用于表示上位机自身流程无法继续,例如 PLC 断连、串口异常、配置缺失、SFTP 连接异常等系统级故障
### 8.5 心跳建议
- `PcOnline` 不建议常亮,建议采用**翻转心跳**方式
- 上位机每 **500ms** 翻转一次 `PcOnline`
- PLC 若在 **3 秒** 内未检测到该位变化,则可判定上位机离线或通信异常
### 8.6 结果寄存器设计(建议)
建议额外使用 Holding Register 表达结果码和统计值。
| 序号 | 地址类型 | 地址 | 名称 | 说明 |
| --- | --- | --- | --- | --- |
| 1 | Holding Register | 40001 | ResultCode | 本次最终结果代码 |
| 2 | Holding Register | 40002 | ScanTryCount | 本次扫码尝试次数 |
| 3 | Holding Register | 40003 | SftpTryCount | 本次 SFTP 查询次数 |
| 4 | Holding Register | 40004 | AlarmCode | 本次报警代码,未报警时为 0 |
| 5 | Holding Register | 40005 | FlowStateCode | 当前流程状态码 |
### 8.7 推荐结果代码定义
| 代码 | 含义 |
| --- | --- |
| 0 | Idle / 无结果 |
| 1 | 处理中 |
| 10 | 扫码成功,文件存在,正常放行 |
| 20 | 扫码失败 3 次后放行 |
| 30 | 扫码成功,文件未找到超时放行 |
| 40 | PLC 通信异常 |
| 41 | 串口异常 |
| 42 | SFTP 连接或认证异常 |
| 43 | 安灯接口调用异常 |
| 44 | 配置异常 |
### 8.8 推荐流程状态码定义
| 代码 | 含义 |
| --- | --- |
| 0 | Idle |
| 1 | Triggered |
| 2 | Scanning |
| 3 | ScanRetrying |
| 4 | CheckingSftp |
| 5 | WaitingSftpRetry |
| 6 | Releasing |
| 7 | Completed |
| 8 | Faulted |
### 8.9 点位清理策略
建议按如下规则清理点位:
- 新板开始前清理:`ScanOk``ScanNg``FileFound``FileNotFound``AlarmRaised``ProcessDone`
- 流程处理中保持:`PcBusy`
- 放行时脉冲输出:`ReleasePermit`
- 流程结束后清理:`PcBusy`
- `PcOnline` 由心跳任务周期性翻转
- `SystemFault` 在故障解除且收到人工复位或 `PlcReset` 后清除
---
## 9. 串口扫码设计建议
### 9.1 配置项
建议配置以下参数:
- 串口号
- 波特率
- 数据位
- 校验位
- 停止位
- 单次扫码超时毫秒数
- 触发命令
- 结束符/返回报文规则
### 9.2 抽象接口建议
扫码服务建议统一为:
- `TriggerScanAsync()`
- 返回 `ScanResult`
`ScanResult` 建议包含:
- `IsSuccess`
- `Barcode`
- `RawMessage`
- `ErrorMessage`
- `DurationMs`
### 9.3 行为建议
- 串口打开失败时立即记录系统故障
- 每次扫码都必须单独设置超时
- 返回空字符串、格式错误或无结束符都视为失败
- 对扫码值仅做基础清洗,不额外加入未确认的业务规则
---
## 10. SFTP 文件查找设计建议
### 10.1 查找策略
建议支持以下任一方式,由配置指定:
1. **完整文件名匹配**
- 例如:`${barcode}.txt`
2. **前缀/后缀匹配**
- 例如:`${barcode}_OK.xml`
3. **目录内模糊匹配**
- 例如:包含二维码 ID 的文件名即视为命中
初版推荐采用**完整文件名匹配**,规则最清晰,误判概率最低。
### 10.2 条码到文件名映射
- 当前版本以**清洗后的二维码字符串**作为查询键
- 推荐默认文件名规则为:`${barcode}.txt`
- 实际文件名后缀或模板通过配置项 `FileNamePattern` 指定
- 若后续现场规则变更,可通过配置调整,不修改流程主逻辑
### 10.3 配置项
- `Host`
- `Port`
- `Username`
- `Password` 或私钥
- `RootPath`
- `FileNamePattern`
- `RetryIntervalSeconds`
- `MaxRetryCount`
- `ConnectTimeoutMs`
### 10.4 行为建议
- 每次查询都要记录当前第几次尝试
- 若 SFTP 连接失败,应区分“连接失败”和“文件不存在”
- 对于连接失败、认证失败、目录错误,不按“文件不存在超时放行”处理,而按系统异常处理
- 若目录配置错误,应作为配置异常处理
---
## 11. 安灯 HTTP 接口设计建议
### 11.1 报警触发场景
当前版本建议至少在以下场景调用安灯接口:
- 扫码连续失败 3 次
后续可扩展场景:
- SFTP 文件未找到超时
- PLC 通信异常
- 串口设备离线
### 11.2 请求内容建议
建议请求体包含:
- 工位编码
- 工位名称
- 报警类型
- 报警编码
- 报警描述
- 二维码内容(若有)
- 触发时间
- 设备名/IP
### 11.3 报警代码建议
| 代码 | 含义 |
| --- | --- |
| 1001 | 扫码连续失败 3 次 |
| 1002 | SFTP 文件超时未找到 |
| 1003 | SFTP 连接异常 |
| 1004 | 串口设备异常 |
| 1005 | PLC 通信异常 |
### 11.4 设计要求
- HTTP 请求必须有超时控制
- 请求失败必须记录日志
- 若报警接口失败,不影响当前“放行”主流程,但需在日志和结果码中体现
---
## 12. 配置设计建议
建议在配置文件中划分如下配置节:
### 12.1 PlcOptions
- IP 地址
- 端口
- 轮询周期
- 连接超时
- 点位地址映射
- 放行脉冲时长
- 放行应答超时时间
### 12.2 ScannerOptions
- 串口参数
- 触发命令
- 单次扫码超时
- 最大扫码次数(当前固定建议值为 3
### 12.3 SftpOptions
- 服务器连接参数
- 根目录
- 文件匹配规则
- 重试等待秒数
- 最大重试次数
### 12.4 AndonOptions
- 是否启用
- 接口地址
- 请求方式
- 超时时间
- 工位编码
- 默认报警码映射
### 12.5 WorkflowOptions
- 启动前是否要求 `PlcReady = 1`
- 启动前是否要求自动模式
- 故障恢复后是否必须人工复位
- 日志保留天数
说明:
- 当前版本“扫码失败 3 次后放行”和“文件未找到超时后放行”属于**固定业务规则**,不作为普通配置开关
- 若未来需要改成拦截模式,应通过需求变更单独评审
---
## 13. UI 展示建议
当前系统虽为简化版,仍建议主界面至少展示以下信息:
- PLC 连接状态
- 扫码枪连接状态
- SFTP 连接状态
- 安灯接口状态
- 当前流程状态
- 当前二维码内容
- 扫码次数
- SFTP 查询次数
- 当前结果描述
- 最近若干条运行日志
建议增加以下按钮:
- 手动复位
- 手动重连 PLC
- 手动重连扫码枪
- 测试安灯接口
- 打开配置
---
## 14. 异常处理策略
### 14.1 业务异常
业务异常指当前板流程内的可接受结果:
- 扫码失败 3 次
- 文件未找到超时
处理原则:
- 记录日志
- 写入明确结果码
- 当前版本仍放行
### 14.2 系统异常
系统异常指上位机自身能力不可用:
- PLC 无法连接
- 串口打开失败
- SFTP 配置缺失或无法认证
- SFTP 根目录不存在
- 安灯接口配置错误
处理原则:
-`SystemFault`
- 写入故障结果码
- 日志详细记录异常上下文
- 进入 `Faulted`
- 故障恢复后需人工复位或 PLC 复位后方可重新接板
### 14.3 防重入要求
- 流程执行中必须禁止重复触发新板流程
- 放行写入必须有一次性保护
- SFTP 查询不得并发重复执行
- 报警接口同一事件只发送一次
---
## 15. 日志与追溯要求
### 15.1 日志级别建议
- `Info`流程开始、扫码结果、SFTP命中、放行完成
- `Warning`:扫码重试、文件未找到等待重试、报警接口失败、放行应答超时
- `Error`PLC/串口/SFTP/HTTP 调用异常、配置异常
### 15.2 单板结果摘要建议
每块 PCB 建议形成一条结果记录,包含:
- 流水时间戳
- 条码
- 扫码总次数
- SFTP 查询次数
- 最终结果码
- 最终结果描述
- 是否放行
- 是否报警
- 异常摘要
---
## 16. 联调与验收建议
### 16.1 联调顺序建议
1. 先打通 PLC 基础读写
2. 再联调扫码枪串口触发
3. 再联调 SFTP 文件查找
4. 再联调安灯 HTTP 接口
5. 最后做整流程串联测试
### 16.2 关键测试场景
1. PLC 到位扫码一次成功SFTP 一次命中,正常放行
2. PLC 到位扫码前两次失败第三次成功SFTP 命中,正常放行
3. PLC 到位,扫码三次失败,安灯报警成功,最终放行
4. PLC 到位扫码成功SFTP 多次未命中,超时后放行
5. PLC 到位,扫码成功,但 SFTP 连接异常,系统进入故障,禁止自动放行
6. 安灯接口调用失败,但扫码失败场景仍按既定规则放行,并有异常日志
7. 流程处理中重复收到到位信号,不允许重复触发
8. PLC 复位后,上位机状态位正确清除
9. PLC 未进入自动模式或未给 `PlcReady` 时,上位机不接板
### 16.3 验收重点
- 是否稳定识别到位触发
- 是否正确限制扫码 3 次总尝试
- 是否按配置进行 SFTP 首次查询和后续 N 次重试
- 是否在规定场景下正确放行
- 是否正确触发安灯报警
- 是否正确区分“文件未找到”和“SFTP 系统异常”
- 是否具备清晰日志和结果码追溯能力
---
## 17. 后续扩展建议
当前设计已为以下扩展预留空间:
- 将扫码失败放行改为配置策略
- 将文件未找到是否报警改为配置策略
- 增加工位编号和多站点配置
- 增加结果明细落库
- 增加人工复位与权限控制
- 增加多工位或多缓存位流程
- 增加 MES 校验或工单绑定
---
## 18. 最终结论
当前项目适合采用**单工位串行状态机 + Modbus TCP + 串口扫码 + SFTP 文件校验 + HTTP 安灯报警**的轻量化上位机方案。
该方案具备以下特点:
- 与当前需求完全匹配
- 软件结构简单清晰
- PLC 点位数量少,易联调
- 能满足“扫码失败放行”和“文件未找到超时放行”的业务要求
- 对 SFTP 系统异常与普通文件未命中进行了明确区分
- 具备后续扩展为更完整目检系统的演进空间
建议后续开发时优先落地:
1. 配置模型
2. PLC 点位通信服务
3. 扫码服务
4. SFTP 校验服务
5. 主流程状态机
6. 主界面状态展示

View File

@@ -39,6 +39,64 @@
- 列宽与绑定不变。
- **流程状态区**:将“流程状态”卡片改为 `hc:Card` 高亮展示,错误信息使用红色文字(不变)。
#### Dashboard 下半区布局方案确认
- 已确认采用 **方案 A双栏中枢布局**
- 设计目标:
- 消除当前页面下半区的大面积空白。
- 强化“当前板状态”和“过程追踪”两个核心视觉中心。
- 将运行日志降为辅助信息,而不是和主业务区争抢注意力。
- 布局原则:
- 左侧承担“当前状态 + 运行日志”主视角,右侧承担“处理记录 + 追踪摘要”副视角。
- 下半区不再平均切成多个小卡片,而是改为“少块、大面、强层级”。
- 通过不同卡片高度、标题层级、留白和分组,建立更清晰的信息优先级。
#### Dashboard 下半区具体布局
- **整体分栏**
- 下半区采用 `1.3 : 0.9` 左右双栏。
- 左栏为主工作区,右栏为过程追踪区。
- **左栏上部**
- 使用两个并排摘要卡片。
- 卡片 1`当前二维码 + 结果码 / 报警码`
- 卡片 2`关键标志 + 扫码次数 + SFTP 次数`
- 当前二维码信息作为左栏上部的第一视觉重点,字体明显大于普通统计值。
- **左栏下部**
- 放置 `最近运行日志`,占据左栏主要高度。
- DataGrid 保留,但应提升表头、行高、内边距和空状态表现,避免“开发态表格感”过强。
- **右栏上部**
- 放置 `最近处理记录摘要`,展示:
- 最近触发时间
- 最近完成时间
- 最后刷新时间
- 该区域应做成信息摘要卡,而不是散落的三行文字。
- **右栏下部**
- 放置 `最近处理记录列表`
- 列表可以保留 DataGrid但视觉上应更轻重点突出时间、条码、结果三列。
- 若 HandyControl 样式允许,可适当弱化网格线,提升卡片式整洁感。
#### Dashboard 下半区视觉风格约束
- 不新增花哨装饰,不改为互联网运营后台风格,保持工业桌面应用的稳重感。
- 卡片层次应依靠以下手段建立,而不是依赖过多边框:
- 标题字号差异
- 数值字号差异
- 卡片阴影和圆角
- 区块留白
- 轻量分隔
- `关键标志` 建议改为更有秩序的纵向状态列表,保留圆点语义,但需统一间距、字号、对齐。
- `结果码 / 报警码` 不再作为独立弱卡片存在,应并入主摘要区,提高信息密度。
- `最近处理记录``最近运行日志` 必须形成主次关系:
- 主日志区更宽,更适合排查问题。
- 处理记录区更紧凑,更适合回看节拍与结果。
#### 本次改造范围
- 仅重构 `DashboardPage.xaml` 的下半区布局与视觉层级。
- 不修改 ViewModel 属性命名、命令绑定和业务逻辑。
- 不新增复杂动画、主题切换、图表控件或第三方可视化组件。
- 若需要新增局部样式或页面资源,应优先放在页面内,避免污染全局资源。
### 4. SystemSettingsPage.xaml
- **TabControl**:使用 HandyControl 的 `TabControl` 样式(默认已覆盖),或显式使用 `hc:TabControl`
- **表单项**
@@ -66,3 +124,4 @@
- 不改写业务逻辑、ViewModel、服务层。
- 不引入 HandyControl 的高级控件Growl、Timeline、Pagination 等),避免过度设计。
- 不替换为其他 UI 库MahApps、MaterialDesign、WPFUI 等)。

View File

@@ -0,0 +1,17 @@
<Application x:Class="AxiOmron.PcbCheck.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AxiOmron.PcbCheck"
xmlns:viewModels="clr-namespace:AxiOmron.PcbCheck.ViewModels"
xmlns:designTime="clr-namespace:AxiOmron.PcbCheck.DesignTime">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<viewModels:ViewModelLocator x:Key="Locator"/>
<designTime:DesignTimeViewModelLocator x:Key="DesignTimeLocator"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,130 @@
using System.IO;
using System.Text;
using System.Windows;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.ViewModels;
using AxiOmron.PcbCheck.Views.Pages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NLog.Extensions.Logging;
namespace AxiOmron.PcbCheck;
/// <summary>
/// 表示 WPF 应用入口,负责 Host/DI、配置和日志初始化。
/// </summary>
public partial class App : Application
{
private IHost? _host;
/// <summary>
/// 获取当前应用服务容器。
/// </summary>
public static IServiceProvider Services { get; private set; } = null!;
/// <summary>
/// 应用启动入口。
/// </summary>
/// <param name="e">启动事件参数。</param>
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
try
{
Console.OutputEncoding = Encoding.UTF8;
}
catch (IOException)
{
// WinExe 在没有控制台句柄时忽略
}
try
{
_host = BuildHost();
await _host.StartAsync().ConfigureAwait(true);
Services = _host.Services;
var mainWindow = Services.GetRequiredService<MainWindow>();
MainWindow = mainWindow;
mainWindow.Show();
}
catch (Exception ex)
{
MessageBox.Show($"应用启动失败: {ex.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(-1);
}
}
/// <summary>
/// 应用退出入口。
/// </summary>
/// <param name="e">退出事件参数。</param>
protected override async void OnExit(ExitEventArgs e)
{
if (_host is not null)
{
try
{
await _host.StopAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
finally
{
_host.Dispose();
}
}
base.OnExit(e);
}
/// <summary>
/// 构建应用 Host 与依赖注入容器。
/// </summary>
/// <returns>已构建的 Host 实例。</returns>
private static IHost BuildHost()
{
return Host.CreateDefaultBuilder()
.ConfigureAppConfiguration((context, configurationBuilder) =>
{
configurationBuilder.SetBasePath(AppContext.BaseDirectory);
configurationBuilder.AddJsonFile("appConfig.json", optional: true, reloadOnChange: false);
configurationBuilder.AddJsonFile($"appConfig.{context.HostingEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: false);
})
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.SetMinimumLevel(LogLevel.Information);
logging.AddNLog();
})
.ConfigureServices((context, services) =>
{
var appConfig = new AppConfig();
context.Configuration.Bind(appConfig);
services.AddSingleton<IConfiguration>(context.Configuration);
services.AddSingleton<IAppConfigService, AppConfigService>();
services.AddSingleton(appConfig);
services.AddSingleton<IAppStateStore, AppStateStore>();
services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>));
services.AddSingleton<IDispatcherService, DispatcherService>();
services.AddHttpClient(nameof(AndonService));
services.AddSingleton<IPlcService, ModbusTcpPlcService>();
services.AddSingleton<IScannerService, SerialScannerService>();
services.AddSingleton<ISftpLookupService, SftpLookupService>();
services.AddSingleton<IAndonService, AndonService>();
services.AddSingleton<WorkflowHostedService>();
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<SystemSettingViewModel>();
services.AddSingleton<DashboardPage>();
services.AddSingleton<SystemSettingsPage>();
services.AddSingleton<MainWindow>();
})
.Build();
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="HandyControl" Version="3.5.1" />
<PackageReference Include="IoTClient" Version="1.0.42" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="NLog" Version="6.0.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="6.0.2" />
<PackageReference Include="SSH.NET" Version="2020.0.2" />
<PackageReference Include="System.IO.Ports" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appConfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="appConfig.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="NLog.config">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace AxiOmron.PcbCheck.Converters;
/// <summary>
/// 将布尔状态转换为界面画刷,颜色与设计色板保持一致。
/// </summary>
public sealed class BooleanToBrushConverter : IValueConverter
{
/// <summary>
/// true 状态使用的柔和绿色画刷(对应 Tag 前景色 #15803D
/// </summary>
private static readonly SolidColorBrush TrueBrush = CreateFrozen(0x15, 0x80, 0x3D);
/// <summary>
/// false 状态使用的柔和红色画刷(对应 Tag 前景色 #B91C1C
/// </summary>
private static readonly SolidColorBrush FalseBrush = CreateFrozen(0xB9, 0x1C, 0x1C);
/// <summary>
/// 将布尔值转换为画刷。
/// </summary>
/// <param name="value">源值。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>状态画刷。</returns>
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? TrueBrush : FalseBrush;
}
/// <summary>
/// 创建并冻结指定颜色的画刷,便于跨线程复用。
/// </summary>
/// <param name="r">红通道。</param>
/// <param name="g">绿通道。</param>
/// <param name="b">蓝通道。</param>
/// <returns>已冻结的画刷实例。</returns>
private static SolidColorBrush CreateFrozen(byte r, byte g, byte b)
{
SolidColorBrush brush = new(Color.FromRgb(r, g, b));
brush.Freeze();
return brush;
}
/// <summary>
/// 不支持反向转换。
/// </summary>
/// <param name="value">源值。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>抛出不支持异常。</returns>
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,67 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace AxiOmron.PcbCheck.Converters;
/// <summary>
/// 将布尔值转换为 <see cref="Visibility"/>,支持通过 ConverterParameter 反转判定。
/// </summary>
/// <remarks>
/// 当 ConverterParameter 为 "Invert"、"Inverse" 或 "!" 时,布尔值的真假意义取反。
/// </remarks>
public sealed class BooleanToVisibilityConverter : IValueConverter
{
/// <summary>
/// 将布尔值转换为可见性。
/// </summary>
/// <param name="value">源布尔值。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">若为 "Invert"/"!" 则反转判定。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns><see cref="Visibility.Visible"/> 或 <see cref="Visibility.Collapsed"/>。</returns>
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;
}
/// <summary>
/// 将可见性反向转换为布尔值。
/// </summary>
/// <param name="value">目标可见性。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">若为 "Invert"/"!" 则反转判定。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>布尔值。</returns>
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;
}
/// <summary>
/// 判断参数是否要求反转判定。
/// </summary>
/// <param name="parameter">参数值。</param>
/// <returns>是否反转。</returns>
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);
}
}

View File

@@ -0,0 +1,54 @@
using System.Globalization;
using System.Windows.Data;
using AxiOmron.PcbCheck.Models;
namespace AxiOmron.PcbCheck.Converters;
/// <summary>
/// 将 <see cref="WorkflowResultCode"/>(以 <see cref="ushort"/> 存储)映射为简短 Tag 文本。
/// </summary>
/// <remarks>
/// 映射规则Passed → "OK"Processing → "处理中"None → "-";其余视为 "NG"。
/// </remarks>
public sealed class ResultCodeToTagTextConverter : IValueConverter
{
/// <summary>
/// 将结果码转换为 Tag 文本。
/// </summary>
/// <param name="value">结果码值。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>简短标签文本。</returns>
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"
};
}
/// <summary>
/// 不支持反向转换。
/// </summary>
/// <param name="value">源值。</param>
/// <param name="targetType">目标类型。</param>
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>抛出不支持异常。</returns>
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,120 @@
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 提供设计时配置服务,返回固定的示例配置数据。
/// </summary>
public sealed class DesignTimeAppConfigService : IAppConfigService
{
private readonly AppConfig _config;
/// <summary>
/// 初始化设计时配置服务。
/// </summary>
public DesignTimeAppConfigService()
{
_config = CreateSampleConfig();
}
/// <summary>
/// 读取设计时配置副本。
/// </summary>
/// <returns>示例根配置对象。</returns>
public AppConfig Load()
{
return CreateSampleConfig();
}
/// <summary>
/// 保存设计时配置,占位实现,仅更新内存中的副本。
/// </summary>
/// <param name="config">待保存的配置对象。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="config"/> 为 <see langword="null"/> 时抛出。</exception>
public void Save(AppConfig config)
{
ArgumentNullException.ThrowIfNull(config);
_config.Plc = config.Plc;
_config.Scanner = config.Scanner;
_config.Sftp = config.Sftp;
_config.Andon = config.Andon;
_config.Workflow = config.Workflow;
}
/// <summary>
/// 获取设计时展示用的示例配置路径。
/// </summary>
/// <returns>固定的设计时配置路径文本。</returns>
public string GetConfigPath()
{
return @"D:\DesignTime\appConfig.Development.json";
}
/// <summary>
/// 创建设计器使用的示例配置对象。
/// </summary>
/// <returns>填充默认值后的配置对象。</returns>
private static AppConfig CreateSampleConfig()
{
return new AppConfig
{
Plc = new PlcOptions
{
Host = "192.168.10.25",
Port = 502,
UnitId = 1,
PollIntervalMs = 200,
ConnectTimeoutMs = 3000,
HeartbeatIntervalMs = 500,
ReleasePulseMs = 450,
ReleaseAckTimeoutMs = 2500
},
Scanner = new ScannerOptions
{
PortName = "COM3",
BaudRate = 9600,
DataBits = 8,
Parity = "None",
StopBits = "One",
ReadTimeoutMs = 2500,
TriggerCommand = "SCAN\\r",
ResponseTerminator = "\\r",
MaxScanAttempts = 3
},
Sftp = new SftpOptions
{
Host = "10.10.20.35",
Port = 22,
Username = "pcb_user",
Password = "******",
PrivateKeyPath = @"C:\Keys\pcb-check.ppk",
RootPath = "/data/pcb",
FileNamePattern = "${barcode}.txt",
RetryIntervalSeconds = 2,
MaxRetryCount = 3,
ConnectTimeoutMs = 3000
},
Andon = new AndonOptions
{
Enable = true,
Url = "http://10.10.20.50/api/andon/alarm",
Method = "POST",
TimeoutMs = 3000,
StationCode = "OMRON-L01",
StationName = "欧姆龙 PCB 检测",
EnableScanFailAlarm = true,
EnableFileNotFoundAlarm = true
},
Workflow = new WorkflowOptions
{
RequirePlcReady = true,
RequireAutoMode = true,
RequireStationEnable = true,
RequireManualResetAfterFault = true,
MaxUiLogEntries = 200,
MaxBoardRecords = 100
}
};
}
}

View File

@@ -0,0 +1,206 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 提供设计时运行态存储,向真实 ViewModel 回放固定的演示数据。
/// </summary>
public sealed class DesignTimeAppStateStore : IAppStateStore
{
private readonly RuntimeSnapshot _snapshot;
private readonly IReadOnlyList<UiLogEntry> _logs;
private readonly IReadOnlyList<BoardProcessRecord> _records;
private EventHandler<RuntimeSnapshot>? _snapshotChanged;
private EventHandler<UiLogEntry>? _logAdded;
private EventHandler<BoardProcessRecord>? _recordAdded;
/// <summary>
/// 初始化设计时运行态存储。
/// </summary>
public DesignTimeAppStateStore()
{
DateTimeOffset now = DateTimeOffset.Now;
_snapshot = new RuntimeSnapshot
{
PlcStatus = "已连接",
ScannerStatus = "在线",
SftpStatus = "可访问",
AndonStatus = "接口正常",
WorkflowState = WorkflowState.CheckingSftp,
WorkflowStateText = WorkflowState.CheckingSftp.ToDisplayText(),
CurrentBarcode = "PCB240417000128",
ResultDescription = "已扫码,等待 SFTP 文件确认",
FaultMessage = string.Empty,
ScanTryCount = 1,
SftpTryCount = 2,
ResultCode = (ushort)WorkflowResultCode.Processing,
AlarmCode = (ushort)AlarmCode.None,
LastTriggeredAt = now.AddSeconds(-18),
LastCompletedAt = now.AddMinutes(-2),
IsBusy = true,
ProcessDone = false,
SystemFault = false,
AlarmRaised = false,
LastUpdatedAt = now
};
_logs = new List<UiLogEntry>
{
new() { Timestamp = now.AddSeconds(-2), Level = "Info", Message = "SFTP 第 2 次查询:正在检查目标文件。" },
new() { Timestamp = now.AddSeconds(-9), Level = "Warning", Message = "第 1 次 SFTP 查询未命中,准备重试。" },
new() { Timestamp = now.AddSeconds(-16), Level = "Info", Message = "扫码成功,条码=PCB240417000128" },
new() { Timestamp = now.AddSeconds(-21), Level = "Info", Message = "检测到 PCB 到位,流程开始执行。" },
new() { Timestamp = now.AddMinutes(-1), Level = "Error", Message = "上一片文件查询超时,已按规则放行。" }
};
_records = new List<BoardProcessRecord>
{
new()
{
StartedAt = now.AddMinutes(-4),
CompletedAt = now.AddMinutes(-3).AddSeconds(-18),
Barcode = "PCB240417000125",
ScanTryCount = 1,
SftpTryCount = 1,
ResultCode = (ushort)WorkflowResultCode.Passed,
ResultDescription = "OK 放行",
ReleaseSent = true,
AlarmRaised = false,
ExceptionSummary = string.Empty
},
new()
{
StartedAt = now.AddMinutes(-3),
CompletedAt = now.AddMinutes(-2).AddSeconds(-12),
Barcode = "PCB240417000126",
ScanTryCount = 3,
SftpTryCount = 0,
ResultCode = (ushort)WorkflowResultCode.ScanFailedReleased,
ResultDescription = "扫码失败后放行",
ReleaseSent = true,
AlarmRaised = true,
ExceptionSummary = "扫码连续失败三次"
},
new()
{
StartedAt = now.AddMinutes(-2),
CompletedAt = now.AddMinutes(-1).AddSeconds(-25),
Barcode = "PCB240417000127",
ScanTryCount = 1,
SftpTryCount = 3,
ResultCode = (ushort)WorkflowResultCode.FileNotFoundReleased,
ResultDescription = "文件超时未找到后放行",
ReleaseSent = true,
AlarmRaised = true,
ExceptionSummary = "SFTP 文件查询超时"
}
};
}
/// <summary>
/// 当运行态快照发生变化时触发。
/// </summary>
public event EventHandler<RuntimeSnapshot>? SnapshotChanged
{
add
{
_snapshotChanged += value;
}
remove
{
_snapshotChanged -= value;
}
}
/// <summary>
/// 当新增日志时触发;订阅时会立即回放现有设计时日志。
/// </summary>
public event EventHandler<UiLogEntry>? LogAdded
{
add
{
if (value is null)
{
return;
}
_logAdded += value;
foreach (UiLogEntry entry in _logs)
{
value(this, entry);
}
}
remove
{
_logAdded -= value;
}
}
/// <summary>
/// 当新增单板记录时触发;订阅时会立即回放现有设计时记录。
/// </summary>
public event EventHandler<BoardProcessRecord>? RecordAdded
{
add
{
if (value is null)
{
return;
}
_recordAdded += value;
foreach (BoardProcessRecord record in _records)
{
value(this, record);
}
}
remove
{
_recordAdded -= value;
}
}
/// <summary>
/// 获取当前设计时快照副本。
/// </summary>
/// <returns>当前快照副本。</returns>
public RuntimeSnapshot GetSnapshot()
{
return _snapshot.Clone();
}
/// <summary>
/// 更新设计时快照并通知订阅者。
/// </summary>
/// <param name="updateAction">用于修改快照的委托。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="updateAction"/> 为 <see langword="null"/> 时抛出。</exception>
public void UpdateSnapshot(Action<RuntimeSnapshot> updateAction)
{
ArgumentNullException.ThrowIfNull(updateAction);
updateAction(_snapshot);
_snapshotChanged?.Invoke(this, _snapshot.Clone());
}
/// <summary>
/// 追加一条设计时日志并通知订阅者。
/// </summary>
/// <param name="entry">待追加的日志对象。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="entry"/> 为 <see langword="null"/> 时抛出。</exception>
public void AddLog(UiLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
_logAdded?.Invoke(this, entry);
}
/// <summary>
/// 追加一条设计时处理记录并通知订阅者。
/// </summary>
/// <param name="record">待追加的记录对象。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="record"/> 为 <see langword="null"/> 时抛出。</exception>
public void AddRecord(BoardProcessRecord record)
{
ArgumentNullException.ThrowIfNull(record);
_recordAdded?.Invoke(this, record);
}
}

View File

@@ -0,0 +1,22 @@
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 提供设计时 Dispatcher 调度能力,直接在当前线程执行委托。
/// </summary>
public sealed class DesignTimeDispatcherService : IDispatcherService
{
/// <summary>
/// 在当前线程中立即执行指定动作。
/// </summary>
/// <param name="action">待执行的动作。</param>
/// <returns>表示执行完成的任务。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,39 @@
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.ViewModels;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 为 XAML 设计器提供基于真实 ViewModel 的设计时定位器。
/// </summary>
public sealed class DesignTimeViewModelLocator
{
private readonly DesignTimeAppStateStore _appStateStore = new();
private readonly DesignTimeDispatcherService _dispatcherService = new();
private readonly DesignTimeWorkflowControlService _workflowControlService = new();
private readonly DesignTimeAppConfigService _appConfigService = new();
private MainWindowViewModel? _mainWindowViewModel;
private SystemSettingViewModel? _systemSettingViewModel;
/// <summary>
/// 获取首页设计时视图模型。
/// </summary>
public MainWindowViewModel MainWindowViewModel
=> _mainWindowViewModel ??= CreateMainWindowViewModel();
/// <summary>
/// 获取系统设置设计时视图模型。
/// </summary>
public SystemSettingViewModel SystemSettingViewModel
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService);
/// <summary>
/// 创建首页设计时视图模型。
/// </summary>
/// <returns>填充了设计时演示数据的真实视图模型实例。</returns>
private MainWindowViewModel CreateMainWindowViewModel()
{
AppConfig config = _appConfigService.Load();
return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config);
}
}

View File

@@ -0,0 +1,57 @@
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 提供设计时流程控制服务,所有命令均为无副作用占位实现。
/// </summary>
public sealed class DesignTimeWorkflowControlService : IWorkflowControlService
{
/// <summary>
/// 模拟手动复位流程命令,不执行实际业务操作。
/// </summary>
/// <param name="cancellationToken">取消令牌;若已取消则立即返回已取消任务。</param>
/// <returns>表示命令已完成的任务。</returns>
public Task ResetAsync(CancellationToken cancellationToken)
{
return cancellationToken.IsCancellationRequested
? Task.FromCanceled(cancellationToken)
: Task.CompletedTask;
}
/// <summary>
/// 模拟 PLC 重连命令,不执行实际设备通信。
/// </summary>
/// <param name="cancellationToken">取消令牌;若已取消则立即返回已取消任务。</param>
/// <returns>表示命令已完成的任务。</returns>
public Task ReconnectPlcAsync(CancellationToken cancellationToken)
{
return cancellationToken.IsCancellationRequested
? Task.FromCanceled(cancellationToken)
: Task.CompletedTask;
}
/// <summary>
/// 模拟扫码枪重连命令,不执行实际设备通信。
/// </summary>
/// <param name="cancellationToken">取消令牌;若已取消则立即返回已取消任务。</param>
/// <returns>表示命令已完成的任务。</returns>
public Task ReconnectScannerAsync(CancellationToken cancellationToken)
{
return cancellationToken.IsCancellationRequested
? Task.FromCanceled(cancellationToken)
: Task.CompletedTask;
}
/// <summary>
/// 模拟安灯测试命令,不执行实际网络请求。
/// </summary>
/// <param name="cancellationToken">取消令牌;若已取消则立即返回已取消任务。</param>
/// <returns>表示命令已完成的任务。</returns>
public Task TestAndonAsync(CancellationToken cancellationToken)
{
return cancellationToken.IsCancellationRequested
? Task.FromCanceled(cancellationToken)
: Task.CompletedTask;
}
}

View File

@@ -0,0 +1,38 @@
<Window x:Class="AxiOmron.PcbCheck.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
mc:Ignorable="d"
Title="Axi Omron PCB Check"
Width="1600"
Height="950"
MinWidth="1400"
MinHeight="860"
Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}"
WindowStartupLocation="CenterScreen">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="80" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Background="{DynamicResource {x:Static hc:ResourceToken.PrimaryBrush}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Vertical" VerticalAlignment="Center" Margin="20,0">
<TextBlock Text="Axi Omron PCB Check" Foreground="White" FontSize="20" FontWeight="Bold" />
<TextBlock Text="单工位串行状态机 / PLC + 扫码枪 + SFTP + 安灯" Foreground="#DBEAFE" FontSize="14" Margin="0,4,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,20,0">
<Button Style="{StaticResource ButtonPrimary}" x:Name="DashboardButton" Click="DashboardButton_OnClick" Content="首页" FontWeight="Bold" FontSize="18" Padding="18,10" Margin="0,0,12,0" Height="50"/>
<Button Style="{StaticResource ButtonPrimary}" x:Name="SettingsButton" Click="SettingsButton_OnClick" Content="系统设置" FontWeight="Bold" FontSize="18" Padding="18,10" Height="50"/>
</StackPanel>
</Grid>
<Frame x:Name="MainFrame" Grid.Row="1" NavigationUIVisibility="Hidden" />
</Grid>
</Window>

View File

@@ -0,0 +1,46 @@
using System.Windows;
using AxiOmron.PcbCheck.Views.Pages;
namespace AxiOmron.PcbCheck;
/// <summary>
/// 表示主窗口,负责页面导航装配。
/// </summary>
public partial class MainWindow : Window
{
private readonly DashboardPage _dashboardPage;
private readonly SystemSettingsPage _systemSettingsPage;
/// <summary>
/// 初始化主窗口。
/// </summary>
/// <param name="dashboardPage">首页页面。</param>
/// <param name="systemSettingsPage">系统设置页面。</param>
public MainWindow(DashboardPage dashboardPage, SystemSettingsPage systemSettingsPage)
{
_dashboardPage = dashboardPage ?? throw new ArgumentNullException(nameof(dashboardPage));
_systemSettingsPage = systemSettingsPage ?? throw new ArgumentNullException(nameof(systemSettingsPage));
InitializeComponent();
MainFrame.Navigate(_dashboardPage);
}
/// <summary>
/// 导航到首页。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
private void DashboardButton_OnClick(object sender, RoutedEventArgs e)
{
MainFrame.Navigate(_dashboardPage);
}
/// <summary>
/// 导航到系统设置页。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
private void SettingsButton_OnClick(object sender, RoutedEventArgs e)
{
MainFrame.Navigate(_systemSettingsPage);
}
}

View File

@@ -0,0 +1,138 @@
namespace AxiOmron.PcbCheck.Models;
/// <summary>
/// 表示当前应用运行态快照,用于界面展示与后台状态同步。
/// </summary>
public sealed class RuntimeSnapshot
{
/// <summary>
/// 获取或设置 PLC 连接状态文本。
/// </summary>
public string PlcStatus { get; set; } = "未连接";
/// <summary>
/// 获取或设置扫码枪连接状态文本。
/// </summary>
public string ScannerStatus { get; set; } = "未验证";
/// <summary>
/// 获取或设置 SFTP 连接状态文本。
/// </summary>
public string SftpStatus { get; set; } = "未验证";
/// <summary>
/// 获取或设置安灯接口状态文本。
/// </summary>
public string AndonStatus { get; set; } = "未验证";
/// <summary>
/// 获取或设置当前流程状态。
/// </summary>
public WorkflowState WorkflowState { get; set; } = WorkflowState.Idle;
/// <summary>
/// 获取或设置当前流程状态文本。
/// </summary>
public string WorkflowStateText { get; set; } = WorkflowState.Idle.ToDisplayText();
/// <summary>
/// 获取或设置当前条码。
/// </summary>
public string CurrentBarcode { get; set; } = string.Empty;
/// <summary>
/// 获取或设置当前结果描述。
/// </summary>
public string ResultDescription { get; set; } = "等待触发";
/// <summary>
/// 获取或设置当前故障信息。
/// </summary>
public string FaultMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置扫码次数。
/// </summary>
public int ScanTryCount { get; set; }
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
public int SftpTryCount { get; set; }
/// <summary>
/// 获取或设置结果代码。
/// </summary>
public ushort ResultCode { get; set; }
/// <summary>
/// 获取或设置报警代码。
/// </summary>
public ushort AlarmCode { get; set; }
/// <summary>
/// 获取或设置上次触发时间。
/// </summary>
public DateTimeOffset? LastTriggeredAt { get; set; }
/// <summary>
/// 获取或设置上次完成时间。
/// </summary>
public DateTimeOffset? LastCompletedAt { get; set; }
/// <summary>
/// 获取或设置 PLC 是否忙碌。
/// </summary>
public bool IsBusy { get; set; }
/// <summary>
/// 获取或设置是否已完成。
/// </summary>
public bool ProcessDone { get; set; }
/// <summary>
/// 获取或设置是否存在系统故障。
/// </summary>
public bool SystemFault { get; set; }
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
public bool AlarmRaised { get; set; }
/// <summary>
/// 获取或设置上次状态刷新时间。
/// </summary>
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
/// <summary>
/// 创建当前快照的副本。
/// </summary>
/// <returns>新的运行态快照副本。</returns>
public RuntimeSnapshot Clone()
{
return new RuntimeSnapshot
{
PlcStatus = PlcStatus,
ScannerStatus = ScannerStatus,
SftpStatus = SftpStatus,
AndonStatus = AndonStatus,
WorkflowState = WorkflowState,
WorkflowStateText = WorkflowStateText,
CurrentBarcode = CurrentBarcode,
ResultDescription = ResultDescription,
FaultMessage = FaultMessage,
ScanTryCount = ScanTryCount,
SftpTryCount = SftpTryCount,
ResultCode = ResultCode,
AlarmCode = AlarmCode,
LastTriggeredAt = LastTriggeredAt,
LastCompletedAt = LastCompletedAt,
IsBusy = IsBusy,
ProcessDone = ProcessDone,
SystemFault = SystemFault,
AlarmRaised = AlarmRaised,
LastUpdatedAt = LastUpdatedAt
};
}
}

View File

@@ -0,0 +1,579 @@
namespace AxiOmron.PcbCheck.Models;
/// <summary>
/// 表示流程状态机中的业务状态。
/// </summary>
public enum WorkflowState
{
/// <summary>
/// 空闲状态。
/// </summary>
Idle = 0,
/// <summary>
/// 已收到触发信号。
/// </summary>
Triggered = 1,
/// <summary>
/// 正在扫码。
/// </summary>
Scanning = 2,
/// <summary>
/// 扫码重试中。
/// </summary>
ScanRetrying = 3,
/// <summary>
/// 扫码失败后放行。
/// </summary>
ScanFailedReleased = 4,
/// <summary>
/// 正在检查 SFTP。
/// </summary>
CheckingSftp = 5,
/// <summary>
/// 正在等待 SFTP 重试。
/// </summary>
WaitingSftpRetry = 6,
/// <summary>
/// SFTP 校验通过。
/// </summary>
SftpPassed = 7,
/// <summary>
/// SFTP 超时后放行。
/// </summary>
SftpTimeoutReleased = 8,
/// <summary>
/// 正在放行。
/// </summary>
Releasing = 9,
/// <summary>
/// 流程已完成。
/// </summary>
Completed = 10,
/// <summary>
/// 系统故障。
/// </summary>
Faulted = 11
}
/// <summary>
/// 表示最终结果代码定义。
/// </summary>
public enum WorkflowResultCode : ushort
{
/// <summary>
/// 无结果。
/// </summary>
None = 0,
/// <summary>
/// 处理中。
/// </summary>
Processing = 1,
/// <summary>
/// 正常放行。
/// </summary>
Passed = 10,
/// <summary>
/// 扫码失败后放行。
/// </summary>
ScanFailedReleased = 20,
/// <summary>
/// 文件未找到超时后放行。
/// </summary>
FileNotFoundReleased = 30,
/// <summary>
/// PLC 通信异常。
/// </summary>
PlcCommunicationFault = 40,
/// <summary>
/// 串口异常。
/// </summary>
ScannerFault = 41,
/// <summary>
/// SFTP 连接或认证异常。
/// </summary>
SftpFault = 42,
/// <summary>
/// 安灯接口调用异常。
/// </summary>
AndonFault = 43,
/// <summary>
/// 配置异常。
/// </summary>
ConfigurationFault = 44
}
/// <summary>
/// 表示报警代码定义。
/// </summary>
public enum AlarmCode : ushort
{
/// <summary>
/// 未报警。
/// </summary>
None = 0,
/// <summary>
/// 扫码连续失败三次。
/// </summary>
ScanFailed = 1001,
/// <summary>
/// SFTP 文件超时未找到。
/// </summary>
FileNotFound = 1002,
/// <summary>
/// SFTP 连接异常。
/// </summary>
SftpFault = 1003,
/// <summary>
/// 串口设备异常。
/// </summary>
ScannerFault = 1004,
/// <summary>
/// PLC 通信异常。
/// </summary>
PlcFault = 1005
}
/// <summary>
/// 表示 PLC 读取到的输入信号快照。
/// </summary>
public sealed class PlcSignalSnapshot
{
/// <summary>
/// 获取或设置 PLC 是否就绪。
/// </summary>
public bool PlcReady { get; set; }
/// <summary>
/// 获取或设置 PCB 是否到位。
/// </summary>
public bool PcbArrived { get; set; }
/// <summary>
/// 获取或设置 PLC 是否请求复位。
/// </summary>
public bool PlcReset { get; set; }
/// <summary>
/// 获取或设置 PLC 是否已应答放行。
/// </summary>
public bool PlcAckRelease { get; set; }
/// <summary>
/// 获取或设置是否为自动模式。
/// </summary>
public bool AutoMode { get; set; }
/// <summary>
/// 获取或设置工位是否使能。
/// </summary>
public bool StationEnable { get; set; }
/// <summary>
/// 获取或设置本次快照采集时间。
/// </summary>
public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.Now;
}
/// <summary>
/// 表示上位机要写入 PLC 的输出状态与寄存器数据。
/// </summary>
public sealed class PlcProcessState
{
/// <summary>
/// 获取或设置 PC 在线位。
/// </summary>
public bool PcOnline { get; set; }
/// <summary>
/// 获取或设置 PC 忙碌位。
/// </summary>
public bool PcBusy { get; set; }
/// <summary>
/// 获取或设置扫码成功位。
/// </summary>
public bool ScanOk { get; set; }
/// <summary>
/// 获取或设置扫码失败位。
/// </summary>
public bool ScanNg { get; set; }
/// <summary>
/// 获取或设置文件找到位。
/// </summary>
public bool FileFound { get; set; }
/// <summary>
/// 获取或设置文件未找到位。
/// </summary>
public bool FileNotFound { get; set; }
/// <summary>
/// 获取或设置报警位。
/// </summary>
public bool AlarmRaised { get; set; }
/// <summary>
/// 获取或设置放行位。
/// </summary>
public bool ReleasePermit { get; set; }
/// <summary>
/// 获取或设置流程完成位。
/// </summary>
public bool ProcessDone { get; set; }
/// <summary>
/// 获取或设置系统故障位。
/// </summary>
public bool SystemFault { get; set; }
/// <summary>
/// 获取或设置结果代码寄存器值。
/// </summary>
public ushort ResultCode { get; set; }
/// <summary>
/// 获取或设置扫码次数寄存器值。
/// </summary>
public ushort ScanTryCount { get; set; }
/// <summary>
/// 获取或设置 SFTP 查询次数寄存器值。
/// </summary>
public ushort SftpTryCount { get; set; }
/// <summary>
/// 获取或设置报警代码寄存器值。
/// </summary>
public ushort AlarmCode { get; set; }
/// <summary>
/// 获取或设置流程状态代码寄存器值。
/// </summary>
public ushort FlowStateCode { get; set; }
/// <summary>
/// 创建当前状态对象的浅拷贝。
/// </summary>
/// <returns>新的 PLC 输出状态对象。</returns>
public PlcProcessState Clone()
{
return new PlcProcessState
{
PcOnline = PcOnline,
PcBusy = PcBusy,
ScanOk = ScanOk,
ScanNg = ScanNg,
FileFound = FileFound,
FileNotFound = FileNotFound,
AlarmRaised = AlarmRaised,
ReleasePermit = ReleasePermit,
ProcessDone = ProcessDone,
SystemFault = SystemFault,
ResultCode = ResultCode,
ScanTryCount = ScanTryCount,
SftpTryCount = SftpTryCount,
AlarmCode = AlarmCode,
FlowStateCode = FlowStateCode
};
}
}
/// <summary>
/// 表示扫码执行结果。
/// </summary>
public sealed class ScanOperationResult
{
/// <summary>
/// 获取或设置扫码是否成功。
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 获取或设置是否为系统级异常。
/// </summary>
public bool IsSystemError { get; set; }
/// <summary>
/// 获取或设置设备连接是否正常。
/// </summary>
public bool DeviceConnected { get; set; }
/// <summary>
/// 获取或设置清洗后的条码值。
/// </summary>
public string Barcode { get; set; } = string.Empty;
/// <summary>
/// 获取或设置原始报文。
/// </summary>
public string RawMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置错误描述。
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置耗时,单位为毫秒。
/// </summary>
public long DurationMs { get; set; }
}
/// <summary>
/// 表示一次 SFTP 文件校验结果。
/// </summary>
public sealed class SftpCheckOutcome
{
/// <summary>
/// 获取或设置目标文件是否存在。
/// </summary>
public bool Exists { get; set; }
/// <summary>
/// 获取或设置是否为系统级异常。
/// </summary>
public bool IsSystemError { get; set; }
/// <summary>
/// 获取或设置是否为配置级异常。
/// </summary>
public bool IsConfigurationError { get; set; }
/// <summary>
/// 获取或设置本次连接是否成功建立。
/// </summary>
public bool ConnectionSucceeded { get; set; }
/// <summary>
/// 获取或设置命中的文件路径。
/// </summary>
public string MatchedFilePath { get; set; } = string.Empty;
/// <summary>
/// 获取或设置错误描述。
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示一次安灯请求。
/// </summary>
public sealed class AndonAlarmRequest
{
/// <summary>
/// 获取或设置报警类型。
/// </summary>
public string AlarmType { get; set; } = string.Empty;
/// <summary>
/// 获取或设置报警代码。
/// </summary>
public ushort AlarmCode { get; set; }
/// <summary>
/// 获取或设置报警描述。
/// </summary>
public string AlarmMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置条码。
/// </summary>
public string Barcode { get; set; } = string.Empty;
/// <summary>
/// 获取或设置触发时间。
/// </summary>
public DateTimeOffset TriggeredAt { get; set; } = DateTimeOffset.Now;
}
/// <summary>
/// 表示一次安灯接口调用结果。
/// </summary>
public sealed class AndonOperationResult
{
/// <summary>
/// 获取或设置调用是否成功。
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 获取或设置终端是否成功到达。
/// </summary>
public bool EndpointReached { get; set; }
/// <summary>
/// 获取或设置 HTTP 状态码。
/// </summary>
public int StatusCode { get; set; }
/// <summary>
/// 获取或设置响应报文。
/// </summary>
public string ResponseBody { get; set; } = string.Empty;
/// <summary>
/// 获取或设置错误描述。
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示 UI 中的一条运行日志。
/// </summary>
public sealed class UiLogEntry
{
/// <summary>
/// 获取或设置日志时间。
/// </summary>
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.Now;
/// <summary>
/// 获取或设置日志级别。
/// </summary>
public string Level { get; set; } = "Info";
/// <summary>
/// 获取或设置日志消息。
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// 表示单板处理结果摘要。
/// </summary>
public sealed class BoardProcessRecord
{
/// <summary>
/// 获取或设置开始时间。
/// </summary>
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.Now;
/// <summary>
/// 获取或设置完成时间。
/// </summary>
public DateTimeOffset CompletedAt { get; set; } = DateTimeOffset.Now;
/// <summary>
/// 获取或设置条码。
/// </summary>
public string Barcode { get; set; } = string.Empty;
/// <summary>
/// 获取或设置扫码次数。
/// </summary>
public int ScanTryCount { get; set; }
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
public int SftpTryCount { get; set; }
/// <summary>
/// 获取或设置结果代码。
/// </summary>
public ushort ResultCode { get; set; }
/// <summary>
/// 获取或设置结果描述。
/// </summary>
public string ResultDescription { get; set; } = string.Empty;
/// <summary>
/// 获取或设置是否已发送放行。
/// </summary>
public bool ReleaseSent { get; set; }
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
public bool AlarmRaised { get; set; }
/// <summary>
/// 获取或设置异常摘要。
/// </summary>
public string ExceptionSummary { get; set; } = string.Empty;
}
/// <summary>
/// 提供流程状态与 PLC 流程代码之间的映射方法。
/// </summary>
internal static class WorkflowStateExtensions
{
/// <summary>
/// 将流程状态映射为 PLC 流程状态码。
/// </summary>
/// <param name="state">待映射的流程状态。</param>
/// <returns>对应的流程状态码。</returns>
public static ushort ToFlowStateCode(this WorkflowState state)
{
return state switch
{
WorkflowState.Idle => 0,
WorkflowState.Triggered => 1,
WorkflowState.Scanning => 2,
WorkflowState.ScanRetrying => 3,
WorkflowState.CheckingSftp => 4,
WorkflowState.WaitingSftpRetry => 5,
WorkflowState.Releasing => 6,
WorkflowState.Completed => 7,
WorkflowState.Faulted => 8,
WorkflowState.ScanFailedReleased => 6,
WorkflowState.SftpPassed => 6,
WorkflowState.SftpTimeoutReleased => 6,
_ => 0
};
}
/// <summary>
/// 将流程状态转换为界面显示文本。
/// </summary>
/// <param name="state">待转换的流程状态。</param>
/// <returns>中文状态描述。</returns>
public static string ToDisplayText(this WorkflowState state)
{
return state switch
{
WorkflowState.Idle => "空闲等待",
WorkflowState.Triggered => "已触发,准备启动流程",
WorkflowState.Scanning => "正在扫码",
WorkflowState.ScanRetrying => "扫码失败,等待重试",
WorkflowState.ScanFailedReleased => "扫码失败放行",
WorkflowState.CheckingSftp => "正在检查 SFTP 文件",
WorkflowState.WaitingSftpRetry => "文件未命中,等待重试",
WorkflowState.SftpPassed => "文件已找到,准备放行",
WorkflowState.SftpTimeoutReleased => "文件未找到超时放行",
WorkflowState.Releasing => "正在向 PLC 发送放行",
WorkflowState.Completed => "流程已完成",
WorkflowState.Faulted => "系统故障",
_ => "未知状态"
};
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true"
internalLogLevel="Warn">
<targets>
<target xsi:type="File"
name="file"
fileName="${basedir}/logs/app-${shortdate}.log"
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
<target xsi:type="Console" name="console" layout="${longdate}|${uppercase:${level}}|${logger}|${message}" />
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="file,console" />
</rules>
</nlog>

View File

@@ -0,0 +1,415 @@
namespace AxiOmron.PcbCheck.Options;
/// <summary>
/// 表示 PCB 目检上位机的根配置对象。
/// </summary>
public sealed class AppConfig
{
/// <summary>
/// 获取或设置 PLC 通信配置。
/// </summary>
public PlcOptions Plc { get; set; } = new();
/// <summary>
/// 获取或设置扫码枪配置。
/// </summary>
public ScannerOptions Scanner { get; set; } = new();
/// <summary>
/// 获取或设置 SFTP 校验配置。
/// </summary>
public SftpOptions Sftp { get; set; } = new();
/// <summary>
/// 获取或设置安灯接口配置。
/// </summary>
public AndonOptions Andon { get; set; } = new();
/// <summary>
/// 获取或设置流程控制配置。
/// </summary>
public WorkflowOptions Workflow { get; set; } = new();
}
/// <summary>
/// 表示 PLC 通信参数与点位映射配置。
/// </summary>
public sealed class PlcOptions
{
/// <summary>
/// 获取或设置 PLC 主机地址。
/// </summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>
/// 获取或设置 PLC 端口。
/// </summary>
public int Port { get; set; } = 502;
/// <summary>
/// 获取或设置 Modbus 从站号。
/// </summary>
public byte UnitId { get; set; } = 1;
/// <summary>
/// 获取或设置轮询周期,单位为毫秒。
/// </summary>
public int PollIntervalMs { get; set; } = 200;
/// <summary>
/// 获取或设置连接超时,单位为毫秒。
/// </summary>
public int ConnectTimeoutMs { get; set; } = 3000;
/// <summary>
/// 获取或设置 PC 在线心跳翻转周期,单位为毫秒。
/// </summary>
public int HeartbeatIntervalMs { get; set; } = 500;
/// <summary>
/// 获取或设置放行脉冲持续时间,单位为毫秒。
/// </summary>
public int ReleasePulseMs { get; set; } = 500;
/// <summary>
/// 获取或设置放行应答超时,单位为毫秒。
/// </summary>
public int ReleaseAckTimeoutMs { get; set; } = 2000;
/// <summary>
/// 获取或设置 PLC 输入点位配置。
/// </summary>
public PlcInputAddressOptions Inputs { get; set; } = new();
/// <summary>
/// 获取或设置 PLC 输出点位配置。
/// </summary>
public PlcOutputAddressOptions Outputs { get; set; } = new();
/// <summary>
/// 获取或设置 PLC 结果寄存器配置。
/// </summary>
public PlcRegisterAddressOptions Registers { get; set; } = new();
}
/// <summary>
/// 表示扫码枪串口参数配置。
/// </summary>
public sealed class ScannerOptions
{
/// <summary>
/// 获取或设置串口号。
/// </summary>
public string PortName { get; set; } = "COM1";
/// <summary>
/// 获取或设置波特率。
/// </summary>
public int BaudRate { get; set; } = 9600;
/// <summary>
/// 获取或设置数据位。
/// </summary>
public int DataBits { get; set; } = 8;
/// <summary>
/// 获取或设置校验位名称。
/// </summary>
public string Parity { get; set; } = "None";
/// <summary>
/// 获取或设置停止位名称。
/// </summary>
public string StopBits { get; set; } = "One";
/// <summary>
/// 获取或设置单次扫码超时,单位为毫秒。
/// </summary>
public int ReadTimeoutMs { get; set; } = 3000;
/// <summary>
/// 获取或设置触发命令,支持转义字符。
/// </summary>
public string TriggerCommand { get; set; } = "SCAN\\r";
/// <summary>
/// 获取或设置返回报文结束符,支持转义字符。
/// </summary>
public string ResponseTerminator { get; set; } = "\\r";
/// <summary>
/// 获取或设置最大扫码尝试次数。
/// </summary>
public int MaxScanAttempts { get; set; } = 3;
}
/// <summary>
/// 表示 SFTP 文件查找配置。
/// </summary>
public sealed class SftpOptions
{
/// <summary>
/// 获取或设置 SFTP 主机地址。
/// </summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>
/// 获取或设置 SFTP 端口。
/// </summary>
public int Port { get; set; } = 22;
/// <summary>
/// 获取或设置登录用户名。
/// </summary>
public string Username { get; set; } = "user";
/// <summary>
/// 获取或设置登录密码。
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// 获取或设置私钥文件路径。
/// </summary>
public string PrivateKeyPath { get; set; } = string.Empty;
/// <summary>
/// 获取或设置私钥口令。
/// </summary>
public string PrivateKeyPassphrase { get; set; } = string.Empty;
/// <summary>
/// 获取或设置根目录。
/// </summary>
public string RootPath { get; set; } = "/pcb";
/// <summary>
/// 获取或设置文件名匹配模板。
/// </summary>
public string FileNamePattern { get; set; } = "${barcode}.txt";
/// <summary>
/// 获取或设置首次未命中后的重试间隔,单位为秒。
/// </summary>
public int RetryIntervalSeconds { get; set; } = 2;
/// <summary>
/// 获取或设置首次未命中后的最大重试次数。
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 获取或设置连接超时,单位为毫秒。
/// </summary>
public int ConnectTimeoutMs { get; set; } = 3000;
}
/// <summary>
/// 表示安灯 HTTP 接口配置。
/// </summary>
public sealed class AndonOptions
{
/// <summary>
/// 获取或设置是否启用安灯接口。
/// </summary>
public bool Enable { get; set; } = true;
/// <summary>
/// 获取或设置安灯接口地址。
/// </summary>
public string Url { get; set; } = "http://127.0.0.1:5000/api/andon";
/// <summary>
/// 获取或设置请求方法。
/// </summary>
public string Method { get; set; } = "POST";
/// <summary>
/// 获取或设置请求超时,单位为毫秒。
/// </summary>
public int TimeoutMs { get; set; } = 3000;
/// <summary>
/// 获取或设置工位编码。
/// </summary>
public string StationCode { get; set; } = "OMRON-01";
/// <summary>
/// 获取或设置工位名称。
/// </summary>
public string StationName { get; set; } = "PCB 目检工位";
/// <summary>
/// 获取或设置扫码失败报警是否启用。
/// </summary>
public bool EnableScanFailAlarm { get; set; } = true;
/// <summary>
/// 获取或设置文件未找到报警是否启用。
/// </summary>
public bool EnableFileNotFoundAlarm { get; set; } = false;
/// <summary>
/// 获取或设置附加请求头。
/// </summary>
public Dictionary<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 表示流程控制公共配置。
/// </summary>
public sealed class WorkflowOptions
{
/// <summary>
/// 获取或设置启动流程前是否要求 PLC 就绪。
/// </summary>
public bool RequirePlcReady { get; set; } = true;
/// <summary>
/// 获取或设置启动流程前是否要求自动模式。
/// </summary>
public bool RequireAutoMode { get; set; } = true;
/// <summary>
/// 获取或设置启动流程前是否要求工位使能。
/// </summary>
public bool RequireStationEnable { get; set; } = true;
/// <summary>
/// 获取或设置故障后是否必须人工复位。
/// </summary>
public bool RequireManualResetAfterFault { get; set; } = true;
/// <summary>
/// 获取或设置 UI 日志最大保留条数。
/// </summary>
public int MaxUiLogEntries { get; set; } = 200;
/// <summary>
/// 获取或设置最近处理记录最大保留条数。
/// </summary>
public int MaxBoardRecords { get; set; } = 100;
}
/// <summary>
/// 表示 PLC 输入点位地址配置。
/// </summary>
public sealed class PlcInputAddressOptions
{
/// <summary>
/// 获取或设置 PLC 就绪点位地址。
/// </summary>
public int PlcReady { get; set; } = 10001;
/// <summary>
/// 获取或设置 PCB 到位点位地址。
/// </summary>
public int PcbArrived { get; set; } = 10002;
/// <summary>
/// 获取或设置 PLC 复位点位地址。
/// </summary>
public int PlcReset { get; set; } = 10003;
/// <summary>
/// 获取或设置 PLC 放行应答点位地址。
/// </summary>
public int PlcAckRelease { get; set; } = 10004;
/// <summary>
/// 获取或设置自动模式点位地址。
/// </summary>
public int AutoMode { get; set; } = 10005;
/// <summary>
/// 获取或设置工位使能点位地址。
/// </summary>
public int StationEnable { get; set; } = 10006;
}
/// <summary>
/// 表示 PLC 输出线圈地址配置。
/// </summary>
public sealed class PlcOutputAddressOptions
{
/// <summary>
/// 获取或设置 PC 在线心跳位地址。
/// </summary>
public int PcOnline { get; set; } = 51;
/// <summary>
/// 获取或设置 PC 忙碌位地址。
/// </summary>
public int PcBusy { get; set; } = 52;
/// <summary>
/// 获取或设置扫码成功位地址。
/// </summary>
public int ScanOk { get; set; } = 53;
/// <summary>
/// 获取或设置扫码失败位地址。
/// </summary>
public int ScanNg { get; set; } = 54;
/// <summary>
/// 获取或设置文件存在位地址。
/// </summary>
public int FileFound { get; set; } = 55;
/// <summary>
/// 获取或设置文件未找到位地址。
/// </summary>
public int FileNotFound { get; set; } = 56;
/// <summary>
/// 获取或设置报警位地址。
/// </summary>
public int AlarmRaised { get; set; } = 57;
/// <summary>
/// 获取或设置放行位地址。
/// </summary>
public int ReleasePermit { get; set; } = 58;
/// <summary>
/// 获取或设置流程完成位地址。
/// </summary>
public int ProcessDone { get; set; } = 59;
/// <summary>
/// 获取或设置系统故障位地址。
/// </summary>
public int SystemFault { get; set; } = 60;
}
/// <summary>
/// 表示 PLC 寄存器地址配置。
/// </summary>
public sealed class PlcRegisterAddressOptions
{
/// <summary>
/// 获取或设置结果代码寄存器地址。
/// </summary>
public int ResultCode { get; set; } = 40001;
/// <summary>
/// 获取或设置扫码次数寄存器地址。
/// </summary>
public int ScanTryCount { get; set; } = 40002;
/// <summary>
/// 获取或设置 SFTP 查询次数寄存器地址。
/// </summary>
public int SftpTryCount { get; set; } = 40003;
/// <summary>
/// 获取或设置报警代码寄存器地址。
/// </summary>
public int AlarmCode { get; set; } = 40004;
/// <summary>
/// 获取或设置流程状态代码寄存器地址。
/// </summary>
public int FlowStateCode { get; set; } = 40005;
}

View File

@@ -0,0 +1,118 @@
using System.Net.Http;
using System.Net.Http.Json;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供安灯 HTTP 接口调用能力。
/// </summary>
public sealed class AndonService : IAndonService
{
private readonly AndonOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IAppLogger<AndonService> _logger;
/// <summary>
/// 初始化安灯服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="httpClientFactory">HttpClient 工厂。</param>
/// <param name="logger">日志记录器。</param>
public AndonService(AppConfig config, IHttpClientFactory httpClientFactory, IAppLogger<AndonService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Andon;
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 发送安灯报警。
/// </summary>
/// <param name="request">报警请求对象。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>报警调用结果。</returns>
public async Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!_options.Enable || string.IsNullOrWhiteSpace(_options.Url))
{
return new AndonOperationResult
{
IsSuccess = false,
EndpointReached = false,
ErrorMessage = "安灯接口未启用或 URL 未配置。"
};
}
try
{
using var client = _httpClientFactory.CreateClient(nameof(AndonService));
client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url)
{
Content = JsonContent.Create(new
{
stationCode = _options.StationCode,
stationName = _options.StationName,
alarmType = request.AlarmType,
alarmCode = request.AlarmCode,
alarmMessage = request.AlarmMessage,
barcode = request.Barcode,
triggeredAt = request.TriggeredAt,
machineName = Environment.MachineName
})
};
foreach (var header in _options.Headers)
{
message.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new AndonOperationResult
{
IsSuccess = response.IsSuccessStatusCode,
EndpointReached = true,
StatusCode = (int)response.StatusCode,
ResponseBody = body,
ErrorMessage = response.IsSuccessStatusCode ? string.Empty : $"HTTP {(int)response.StatusCode}: {body}"
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "安灯接口调用失败");
return new AndonOperationResult
{
IsSuccess = false,
EndpointReached = false,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 发送一次测试报警请求。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>测试调用结果。</returns>
public Task<AndonOperationResult> TestAsync(CancellationToken cancellationToken)
{
return RaiseAlarmAsync(new AndonAlarmRequest
{
AlarmType = "ManualTest",
AlarmCode = (ushort)AlarmCode.ScanFailed,
AlarmMessage = "手动测试安灯接口",
Barcode = string.Empty,
TriggeredAt = DateTimeOffset.Now
}, cancellationToken);
}
}

View File

@@ -0,0 +1,65 @@
using System.IO;
using System.Text.Json;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供应用配置文件的读取与保存能力。
/// </summary>
public sealed class AppConfigService : IAppConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
/// <summary>
/// 读取当前应用配置。
/// </summary>
/// <returns>根配置对象。</returns>
public AppConfig Load()
{
var configPath = GetConfigPath();
if (!File.Exists(configPath))
{
var defaultConfig = new AppConfig();
Save(defaultConfig);
return defaultConfig;
}
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json, JsonOptions);
return config ?? new AppConfig();
}
/// <summary>
/// 保存当前应用配置。
/// </summary>
/// <param name="config">待保存的配置对象。</param>
public void Save(AppConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var configPath = GetConfigPath();
var directory = Path.GetDirectoryName(configPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(configPath, json);
}
/// <summary>
/// 获取主配置文件路径。
/// </summary>
/// <returns>配置文件绝对路径。</returns>
public string GetConfigPath()
{
return Path.Combine(AppContext.BaseDirectory, "appConfig.json");
}
}

View File

@@ -0,0 +1,154 @@
using System.Globalization;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供应用统一日志能力,并在需要时同步前台 UI 日志。
/// </summary>
/// <typeparam name="TCategoryName">日志分类类型。</typeparam>
public sealed class AppLogger<TCategoryName> : IAppLogger<TCategoryName>
{
private readonly ILogger<TCategoryName> _logger;
private readonly IAppStateStore _stateStore;
/// <summary>
/// 初始化统一日志服务。
/// </summary>
/// <param name="logger">底层标准日志记录器。</param>
/// <param name="stateStore">运行态存储。</param>
public AppLogger(ILogger<TCategoryName> logger, IAppStateStore stateStore)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
}
/// <summary>
/// 记录一条信息日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogInformation(string message, bool showInUi = false, params object?[] args)
{
_logger.LogInformation(message, args);
PublishUiLog(LogLevel.Information, message, null, showInUi, args);
}
/// <summary>
/// 记录一条警告日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogWarning(string message, bool showInUi = false, params object?[] args)
{
_logger.LogWarning(message, args);
PublishUiLog(LogLevel.Warning, message, null, showInUi, args);
}
/// <summary>
/// 记录一条带异常的警告日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogWarning(exception, message, args);
PublishUiLog(LogLevel.Warning, message, exception, showInUi, args);
}
/// <summary>
/// 记录一条错误日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
public void LogError(string message, bool showInUi = false, params object?[] args)
{
_logger.LogError(message, args);
PublishUiLog(LogLevel.Error, message, null, showInUi, args);
}
/// <summary>
/// 记录一条带异常的错误日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogError(exception, message, args);
PublishUiLog(LogLevel.Error, message, exception, showInUi, args);
}
/// <summary>
/// 按需向前台运行态发布日志。
/// </summary>
/// <param name="logLevel">日志级别。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="exception">异常对象。</param>
/// <param name="showInUi">是否显示到前台。</param>
/// <param name="args">日志模板参数。</param>
private void PublishUiLog(LogLevel logLevel, string message, Exception? exception, bool showInUi, params object?[] args)
{
if (!showInUi)
{
return;
}
var formattedMessage = FormatMessage(message, args);
if (exception is not null)
{
formattedMessage = string.IsNullOrWhiteSpace(formattedMessage)
? exception.Message
: $"{formattedMessage}: {exception.Message}";
}
_stateStore.AddLog(new UiLogEntry
{
Level = logLevel.ToString(),
Message = formattedMessage,
Timestamp = DateTimeOffset.Now
});
}
/// <summary>
/// 将日志模板与参数格式化为可展示文本。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="args">日志模板参数。</param>
/// <returns>格式化后的文本。</returns>
private static string FormatMessage(string message, params object?[] args)
{
if (args.Length == 0)
{
return message;
}
var formattedMessage = message;
for (var index = 0; index < args.Length; index++)
{
var replacement = Convert.ToString(args[index], CultureInfo.InvariantCulture) ?? string.Empty;
var tokenStart = formattedMessage.IndexOf('{', StringComparison.Ordinal);
var tokenEnd = tokenStart >= 0 ? formattedMessage.IndexOf('}', tokenStart + 1) : -1;
if (tokenStart < 0 || tokenEnd <= tokenStart)
{
break;
}
formattedMessage = formattedMessage.Remove(tokenStart, tokenEnd - tokenStart + 1).Insert(tokenStart, replacement);
}
return formattedMessage;
}
}

View File

@@ -0,0 +1,79 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供运行态快照、日志与单板记录的线程安全存储能力。
/// </summary>
public sealed class AppStateStore : IAppStateStore
{
private readonly object _syncRoot = new();
private RuntimeSnapshot _snapshot = new();
/// <summary>
/// 当运行态快照变化时触发。
/// </summary>
public event EventHandler<RuntimeSnapshot>? SnapshotChanged;
/// <summary>
/// 当新增日志时触发。
/// </summary>
public event EventHandler<UiLogEntry>? LogAdded;
/// <summary>
/// 当新增单板记录时触发。
/// </summary>
public event EventHandler<BoardProcessRecord>? RecordAdded;
/// <summary>
/// 获取当前运行态快照副本。
/// </summary>
/// <returns>当前快照副本。</returns>
public RuntimeSnapshot GetSnapshot()
{
lock (_syncRoot)
{
return _snapshot.Clone();
}
}
/// <summary>
/// 更新当前运行态快照。
/// </summary>
/// <param name="updateAction">用于修改快照的更新委托。</param>
public void UpdateSnapshot(Action<RuntimeSnapshot> updateAction)
{
ArgumentNullException.ThrowIfNull(updateAction);
RuntimeSnapshot clonedSnapshot;
lock (_syncRoot)
{
updateAction(_snapshot);
_snapshot.LastUpdatedAt = DateTimeOffset.Now;
clonedSnapshot = _snapshot.Clone();
}
SnapshotChanged?.Invoke(this, clonedSnapshot);
}
/// <summary>
/// 追加一条 UI 日志。
/// </summary>
/// <param name="entry">待追加的日志对象。</param>
public void AddLog(UiLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
LogAdded?.Invoke(this, entry);
}
/// <summary>
/// 追加一条单板结果记录。
/// </summary>
/// <param name="record">待追加的记录对象。</param>
public void AddRecord(BoardProcessRecord record)
{
ArgumentNullException.ThrowIfNull(record);
RecordAdded?.Invoke(this, record);
}
}

View File

@@ -0,0 +1,23 @@
using System.Windows.Threading;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供切回 WPF UI 线程的调度能力。
/// </summary>
public sealed class DispatcherService : IDispatcherService
{
/// <summary>
/// 在 UI 线程中执行指定动作。
/// </summary>
/// <param name="action">待执行的动作。</param>
/// <returns>表示调度完成的任务。</returns>
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
var dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
return dispatcher.InvokeAsync(action).Task;
}
}

View File

@@ -0,0 +1,257 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
using IoTClient.Clients.Modbus;
using IoTClient.Enums;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供基于 IoTClient ModbusTcpClient 的 PLC 读写能力。
/// </summary>
public sealed class ModbusTcpPlcService : IPlcService, IDisposable
{
private readonly PlcOptions _options;
private readonly IAppLogger<ModbusTcpPlcService> _logger;
private readonly SemaphoreSlim _ioLock = new(1, 1);
private ModbusTcpClient? _client;
private PlcProcessState? _lastWrittenState;
private bool _disposed;
/// <summary>
/// 初始化 PLC 通信服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public ModbusTcpPlcService(AppConfig config, IAppLogger<ModbusTcpPlcService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Plc;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 读取 PLC 输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>输入信号快照。</returns>
public async Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
EnsureConnected();
return new PlcSignalSnapshot
{
PlcReady = ReadDiscrete(_options.Inputs.PlcReady),
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
AutoMode = ReadDiscrete(_options.Inputs.AutoMode),
StationEnable = ReadDiscrete(_options.Inputs.StationEnable),
CapturedAt = DateTimeOffset.Now
};
}
catch
{
DisconnectUnsafe();
throw;
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 写入 PLC 输出状态与寄存器值。
/// </summary>
/// <param name="state">待写入的输出状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示写入完成的任务。</returns>
public async Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
EnsureConnected();
WriteChangedState(state);
_lastWrittenState = state.Clone();
}
catch
{
DisconnectUnsafe();
throw;
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 主动断开并重建 PLC 连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
public async Task ForceReconnectAsync(CancellationToken cancellationToken)
{
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
DisconnectUnsafe();
_lastWrittenState = null;
EnsureConnected();
}
finally
{
_ioLock.Release();
}
}
/// <summary>
/// 释放 PLC 通信资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
DisconnectUnsafe();
_ioLock.Dispose();
}
/// <summary>
/// 确保 IoTClient Modbus TCP 客户端已连接。
/// </summary>
private void EnsureConnected()
{
if (_client is { Connected: true })
{
return;
}
DisconnectUnsafe();
var client = new ModbusTcpClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD, false);
var openResult = client.Open();
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 PLC 失败");
_client = client;
_logger.LogInformation("已通过 IoTClient 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
}
/// <summary>
/// 读取单个离散输入位。
/// </summary>
/// <param name="address">离散输入地址。</param>
/// <returns>读取到的布尔值。</returns>
private bool ReadDiscrete(int address)
{
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.ReadDiscrete(address, _options.UnitId, 2);
EnsureSuccess(result.IsSucceed, result.Err, $"读取离散输入失败,地址={address}");
return result.Value;
}
/// <summary>
/// 写入所有发生变化的线圈位与寄存器。
/// </summary>
/// <param name="state">目标状态。</param>
private void WriteChangedState(PlcProcessState state)
{
var previous = _lastWrittenState;
WriteSingleCoilIfChanged(previous?.PcOnline, state.PcOnline, _options.Outputs.PcOnline);
WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy);
WriteSingleCoilIfChanged(previous?.ScanOk, state.ScanOk, _options.Outputs.ScanOk);
WriteSingleCoilIfChanged(previous?.ScanNg, state.ScanNg, _options.Outputs.ScanNg);
WriteSingleCoilIfChanged(previous?.FileFound, state.FileFound, _options.Outputs.FileFound);
WriteSingleCoilIfChanged(previous?.FileNotFound, state.FileNotFound, _options.Outputs.FileNotFound);
WriteSingleCoilIfChanged(previous?.AlarmRaised, state.AlarmRaised, _options.Outputs.AlarmRaised);
WriteSingleCoilIfChanged(previous?.ReleasePermit, state.ReleasePermit, _options.Outputs.ReleasePermit);
WriteSingleCoilIfChanged(previous?.ProcessDone, state.ProcessDone, _options.Outputs.ProcessDone);
WriteSingleCoilIfChanged(previous?.SystemFault, state.SystemFault, _options.Outputs.SystemFault);
WriteSingleRegisterIfChanged(previous?.ResultCode, state.ResultCode, _options.Registers.ResultCode);
WriteSingleRegisterIfChanged(previous?.ScanTryCount, state.ScanTryCount, _options.Registers.ScanTryCount);
WriteSingleRegisterIfChanged(previous?.SftpTryCount, state.SftpTryCount, _options.Registers.SftpTryCount);
WriteSingleRegisterIfChanged(previous?.AlarmCode, state.AlarmCode, _options.Registers.AlarmCode);
WriteSingleRegisterIfChanged(previous?.FlowStateCode, state.FlowStateCode, _options.Registers.FlowStateCode);
}
/// <summary>
/// 仅当值发生变化时写入单个线圈位。
/// </summary>
/// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param>
/// <param name="address">线圈地址。</param>
private void WriteSingleCoilIfChanged(bool? previous, bool current, int address)
{
if (previous.HasValue && previous.Value == current)
{
return;
}
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.Write(address.ToString(), current, _options.UnitId, 5);
EnsureSuccess(result.IsSucceed, result.Err, $"写入线圈失败,地址={address},值={current}");
}
/// <summary>
/// 仅当值发生变化时写入单个保持寄存器。
/// </summary>
/// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param>
/// <param name="address">保持寄存器地址。</param>
private void WriteSingleRegisterIfChanged(ushort? previous, ushort current, int address)
{
if (previous.HasValue && previous.Value == current)
{
return;
}
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = client.Write(address.ToString(), current, _options.UnitId, 6);
EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}");
}
/// <summary>
/// 校验 IoTClient 调用结果是否成功。
/// </summary>
/// <param name="isSucceed">调用是否成功。</param>
/// <param name="error">错误消息。</param>
/// <param name="message">异常消息前缀。</param>
private static void EnsureSuccess(bool isSucceed, string? error, string message)
{
if (!isSucceed)
{
throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? message : $"{message}: {error}");
}
}
/// <summary>
/// 断开当前 PLC 客户端连接。
/// </summary>
private void DisconnectUnsafe()
{
try
{
_client?.Close();
}
catch
{
// 忽略关闭异常。
}
finally
{
_client = null;
}
}
}

View File

@@ -0,0 +1,319 @@
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;
/// <summary>
/// 提供串口扫码枪的触发与读取能力。
/// </summary>
public sealed class SerialScannerService : IScannerService, IDisposable
{
private readonly ScannerOptions _options;
private readonly IAppLogger<SerialScannerService> _logger;
private readonly SemaphoreSlim _ioLock = new(1, 1);
private SerialPort? _serialPort;
private bool _disposed;
/// <summary>
/// 初始化扫码枪服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public SerialScannerService(AppConfig config, IAppLogger<SerialScannerService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Scanner;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 触发一次扫码。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
public async Task<ScanOperationResult> 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();
}
}
}
/// <summary>
/// 测试扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>连接正常返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public async Task<bool> 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();
}
}
}
/// <summary>
/// 主动断开并重建扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
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();
}
}
}
/// <summary>
/// 释放扫码枪串口资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
ClosePortUnsafe();
_ioLock.Dispose();
}
/// <summary>
/// 在异步上下文中执行一次扫码流程。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
private async Task<ScanOperationResult> 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
};
}
}
/// <summary>
/// 确保串口已打开并按当前配置初始化。
/// </summary>
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<Parity>(_options.Parity, true);
var stopBits = Enum.Parse<StopBits>(_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);
}
/// <summary>
/// 从串口读取直到遇到终止符或超时。
/// </summary>
/// <param name="port">串口实例。</param>
/// <param name="terminator">终止符。</param>
/// <param name="timeoutMs">总超时时间,单位为毫秒。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>读取到的原始字符串。</returns>
private static async Task<string> 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("扫码枪在规定时间内未返回完整报文。");
}
/// <summary>
/// 关闭当前串口并释放资源。
/// </summary>
private void ClosePortUnsafe()
{
try
{
if (_serialPort?.IsOpen == true)
{
_serialPort.Close();
}
}
catch
{
// 忽略关闭异常。
}
finally
{
_serialPort?.Dispose();
_serialPort = null;
}
}
}

View File

@@ -0,0 +1,200 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Utils;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供 SFTP 文件存在性校验能力。
/// </summary>
public sealed class SftpLookupService : ISftpLookupService
{
private readonly SftpOptions _options;
private readonly IAppLogger<SftpLookupService> _logger;
/// <summary>
/// 初始化 SFTP 校验服务。
/// </summary>
/// <param name="config">应用根配置。</param>
/// <param name="logger">日志记录器。</param>
public SftpLookupService(AppConfig config, IAppLogger<SftpLookupService> logger)
{
ArgumentNullException.ThrowIfNull(config);
_options = config.Sftp;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 按条码检查目标文件是否存在。
/// </summary>
/// <param name="barcode">条码内容。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件校验结果。</returns>
public async Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(barcode))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ErrorMessage = "条码为空,无法执行 SFTP 查询。"
};
}
if (string.IsNullOrWhiteSpace(_options.Host) || string.IsNullOrWhiteSpace(_options.RootPath))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ErrorMessage = "SFTP 配置缺失 Host 或 RootPath。"
};
}
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 在同步上下文中执行 SFTP 查询。
/// </summary>
/// <param name="barcode">条码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>查询结果。</returns>
private SftpCheckOutcome CheckInternal(string barcode, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
using var client = CreateClient();
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs);
client.Connect();
if (!client.IsConnected)
{
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = "SFTP 未能建立连接。"
};
}
var candidateName = BuildExpectedFileName(barcode);
var rootPath = NormalizeDirectory(_options.RootPath);
if (!client.Exists(rootPath))
{
return new SftpCheckOutcome
{
Exists = false,
IsConfigurationError = true,
ConnectionSucceeded = true,
ErrorMessage = $"SFTP 根目录不存在: {rootPath}"
};
}
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));
if (matched is null)
{
return new SftpCheckOutcome
{
Exists = false,
ConnectionSucceeded = true,
ErrorMessage = $"未找到与条码 {barcode} 匹配的文件。"
};
}
return new SftpCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = matched.FullName
};
}
catch (OperationCanceledException)
{
throw;
}
catch (SshAuthenticationException ex)
{
_logger.LogError(ex, "SFTP 认证失败");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = $"SFTP 认证失败: {ex.Message}"
};
}
catch (SshConnectionException ex)
{
_logger.LogError(ex, "SFTP 连接失败");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = $"SFTP 连接失败: {ex.Message}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "SFTP 查询异常");
return new SftpCheckOutcome
{
Exists = false,
IsSystemError = true,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 根据当前配置创建 SFTP 客户端。
/// </summary>
/// <returns>SFTP 客户端实例。</returns>
private SftpClient CreateClient()
{
if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
{
var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase)
? new PrivateKeyFile(_options.PrivateKeyPath)
: new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase);
var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile);
var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth);
return new SftpClient(connectionInfo);
}
return new SftpClient(_options.Host, _options.Port, _options.Username, _options.Password);
}
/// <summary>
/// 根据条码和模板构建预期文件名。
/// </summary>
/// <param name="barcode">条码。</param>
/// <returns>预期文件名或匹配模式。</returns>
private string BuildExpectedFileName(string barcode)
{
var pattern = string.IsNullOrWhiteSpace(_options.FileNamePattern) ? "${barcode}.txt" : _options.FileNamePattern;
return pattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 统一目录路径格式。
/// </summary>
/// <param name="path">原始目录路径。</param>
/// <returns>标准化目录路径。</returns>
private static string NormalizeDirectory(string path)
{
return path.Replace('\\', '/').TrimEnd('/');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
namespace AxiOmron.PcbCheck.Services.Interfaces;
/// <summary>
/// 定义应用配置文件读写能力。
/// </summary>
public interface IAppConfigService
{
/// <summary>
/// 读取当前应用配置。
/// </summary>
/// <returns>根配置对象。</returns>
AppConfig Load();
/// <summary>
/// 保存当前应用配置。
/// </summary>
/// <param name="config">待保存的配置对象。</param>
void Save(AppConfig config);
/// <summary>
/// 获取主配置文件路径。
/// </summary>
/// <returns>配置文件绝对路径。</returns>
string GetConfigPath();
}
/// <summary>
/// 定义 WPF Dispatcher 调度能力。
/// </summary>
public interface IDispatcherService
{
/// <summary>
/// 在 UI 线程中执行指定动作。
/// </summary>
/// <param name="action">待执行的动作。</param>
/// <returns>表示调度完成的任务。</returns>
Task InvokeAsync(Action action);
}
/// <summary>
/// 定义应用统一日志能力,同时兼容持久化日志与前台 UI 日志分发。
/// </summary>
/// <typeparam name="TCategoryName">日志分类类型。</typeparam>
public interface IAppLogger<TCategoryName>
{
/// <summary>
/// 记录一条信息日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogInformation(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条警告日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogWarning(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条带异常的警告日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条错误日志。
/// </summary>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
void LogError(string message, bool showInUi = false, params object?[] args);
/// <summary>
/// 记录一条带异常的错误日志。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="message">日志消息模板或文本。</param>
/// <param name="showInUi">是否同步显示到前台日志区域。默认不显示。</param>
/// <param name="args">日志模板参数。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="exception"/> 为 <see langword="null"/> 时抛出。</exception>
void LogError(Exception exception, string message, bool showInUi = false, params object?[] args);
}
/// <summary>
/// 定义 PLC 通信能力。
/// </summary>
public interface IPlcService
{
/// <summary>
/// 读取 PLC 输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>输入信号快照。</returns>
Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken);
/// <summary>
/// 写入 PLC 输出状态与寄存器值。
/// </summary>
/// <param name="state">待写入的输出状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示写入完成的任务。</returns>
Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken);
/// <summary>
/// 主动断开并重建 PLC 连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义扫码枪服务能力。
/// </summary>
public interface IScannerService
{
/// <summary>
/// 触发一次扫码。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>扫码结果。</returns>
Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken);
/// <summary>
/// 测试扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>连接正常返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
Task<bool> TestConnectionAsync(CancellationToken cancellationToken);
/// <summary>
/// 主动断开并重建扫码枪连接。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义 SFTP 校验能力。
/// </summary>
public interface ISftpLookupService
{
/// <summary>
/// 按条码检查目标文件是否存在。
/// </summary>
/// <param name="barcode">条码内容。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件校验结果。</returns>
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
}
/// <summary>
/// 定义安灯接口调用能力。
/// </summary>
public interface IAndonService
{
/// <summary>
/// 发送安灯报警。
/// </summary>
/// <param name="request">报警请求对象。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>报警调用结果。</returns>
Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken);
/// <summary>
/// 发送一次测试报警请求。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>测试调用结果。</returns>
Task<AndonOperationResult> TestAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义流程控制手动操作能力。
/// </summary>
public interface IWorkflowControlService
{
/// <summary>
/// 手动复位流程状态。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示复位完成的任务。</returns>
Task ResetAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动重连 PLC。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ReconnectPlcAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动重连扫码枪。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ReconnectScannerAsync(CancellationToken cancellationToken);
/// <summary>
/// 手动测试安灯接口。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示测试完成的任务。</returns>
Task TestAndonAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义运行态快照与 UI 事件分发能力。
/// </summary>
public interface IAppStateStore
{
/// <summary>
/// 当运行态快照变化时触发。
/// </summary>
event EventHandler<RuntimeSnapshot>? SnapshotChanged;
/// <summary>
/// 当新增日志时触发。
/// </summary>
event EventHandler<UiLogEntry>? LogAdded;
/// <summary>
/// 当新增单板记录时触发。
/// </summary>
event EventHandler<BoardProcessRecord>? RecordAdded;
/// <summary>
/// 获取当前运行态快照副本。
/// </summary>
/// <returns>当前快照副本。</returns>
RuntimeSnapshot GetSnapshot();
/// <summary>
/// 更新当前运行态快照。
/// </summary>
/// <param name="updateAction">用于修改快照的更新委托。</param>
void UpdateSnapshot(Action<RuntimeSnapshot> updateAction);
/// <summary>
/// 追加一条 UI 日志。
/// </summary>
/// <param name="entry">待追加的日志对象。</param>
void AddLog(UiLogEntry entry);
/// <summary>
/// 追加一条单板结果记录。
/// </summary>
/// <param name="record">待追加的记录对象。</param>
void AddRecord(BoardProcessRecord record);
}

View File

@@ -0,0 +1,135 @@
using System.Text;
using System.Text.RegularExpressions;
namespace AxiOmron.PcbCheck.Utils;
/// <summary>
/// 提供转义字符串处理能力。
/// </summary>
internal static class StringEscapeHelper
{
/// <summary>
/// 将配置字符串中的常见转义序列还原为实际字符。
/// </summary>
/// <param name="value">待还原的字符串。</param>
/// <returns>还原后的字符串。</returns>
public static string Unescape(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("\\r", "\r", StringComparison.Ordinal)
.Replace("\\n", "\n", StringComparison.Ordinal)
.Replace("\\t", "\t", StringComparison.Ordinal)
.Replace("\\0", "\0", StringComparison.Ordinal);
}
}
/// <summary>
/// 提供扫码字符串清洗能力。
/// </summary>
internal static class BarcodeCleaner
{
/// <summary>
/// 去除条码中的首尾空白与控制字符。
/// </summary>
/// <param name="value">原始条码字符串。</param>
/// <returns>清洗后的条码。</returns>
public static string Clean(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var builder = new StringBuilder(value.Length);
foreach (var character in value.Trim())
{
if (!char.IsControl(character))
{
builder.Append(character);
}
}
return builder.ToString().Trim();
}
}
/// <summary>
/// 提供通配符匹配能力。
/// </summary>
internal static class WildcardMatcher
{
/// <summary>
/// 判断给定文本是否匹配通配符模式。
/// </summary>
/// <param name="text">待匹配文本。</param>
/// <param name="pattern">通配符模式。</param>
/// <returns>匹配成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public static bool IsMatch(string text, string pattern)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentNullException.ThrowIfNull(pattern);
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*", StringComparison.Ordinal)
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
return Regex.IsMatch(text, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}
/// <summary>
/// 提供 Modbus 地址转换能力。
/// </summary>
internal static class ModbusAddressConverter
{
/// <summary>
/// 将离散输入地址转换为零基偏移。
/// </summary>
/// <param name="address">离散输入地址。</param>
/// <returns>零基偏移。</returns>
public static ushort ToDiscreteInputOffset(int address)
{
return ConvertToOffset(address, 10001);
}
/// <summary>
/// 将线圈地址转换为零基偏移。
/// </summary>
/// <param name="address">线圈地址。</param>
/// <returns>零基偏移。</returns>
public static ushort ToCoilOffset(int address)
{
return ConvertToOffset(address, 1);
}
/// <summary>
/// 将保持寄存器地址转换为零基偏移。
/// </summary>
/// <param name="address">保持寄存器地址。</param>
/// <returns>零基偏移。</returns>
public static ushort ToHoldingRegisterOffset(int address)
{
return ConvertToOffset(address, 40001);
}
/// <summary>
/// 根据基地址执行统一偏移换算。
/// </summary>
/// <param name="address">原始地址。</param>
/// <param name="baseAddress">地址段起始基值。</param>
/// <returns>零基偏移。</returns>
private static ushort ConvertToOffset(int address, int baseAddress)
{
if (address < baseAddress)
{
throw new ArgumentOutOfRangeException(nameof(address), $"地址 {address} 小于基地址 {baseAddress}。");
}
return checked((ushort)(address - baseAddress));
}
}

View File

@@ -0,0 +1,505 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.ViewModels;
/// <summary>
/// 提供主窗口与首页共享的运行状态、日志和命令绑定能力。
/// </summary>
public partial class MainWindowViewModel : ObservableObject
{
private readonly IAppStateStore _stateStore;
private readonly IDispatcherService _dispatcherService;
private readonly IWorkflowControlService _workflowControlService;
private readonly WorkflowOptions _workflowOptions;
/// <summary>
/// 初始化主窗口视图模型。
/// </summary>
/// <param name="stateStore">运行态存储。</param>
/// <param name="dispatcherService">Dispatcher 调度服务。</param>
/// <param name="workflowControlService">流程控制服务。</param>
/// <param name="config">应用配置。</param>
public MainWindowViewModel(
IAppStateStore stateStore,
IDispatcherService dispatcherService,
IWorkflowControlService workflowControlService,
AppConfig config)
{
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
_workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService));
ArgumentNullException.ThrowIfNull(config);
_workflowOptions = config.Workflow;
Title = "Axi Omron PCB Check";
Logs = new ObservableCollection<UiLogEntry>();
RecentBoards = new ObservableCollection<BoardProcessRecord>();
Logs.CollectionChanged += OnLogsCollectionChanged;
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
_stateStore.SnapshotChanged += OnSnapshotChanged;
_stateStore.LogAdded += OnLogAdded;
_stateStore.RecordAdded += OnRecordAdded;
ApplySnapshot(_stateStore.GetSnapshot());
RecalculateLogStatistics();
RecalculateProcessStatistics();
}
/// <summary>
/// 获取运行日志集合。
/// </summary>
public ObservableCollection<UiLogEntry> Logs { get; }
/// <summary>
/// 获取最近处理记录集合。
/// </summary>
public ObservableCollection<BoardProcessRecord> RecentBoards { get; }
/// <summary>
/// 获取最近运行日志集合(与 <see cref="Logs"/> 为同一集合,供 UI 绑定语义更清晰)。
/// </summary>
public ObservableCollection<UiLogEntry> RecentLogs => Logs;
/// <summary>
/// 获取最近处理记录集合(与 <see cref="RecentBoards"/> 为同一集合,供 UI 绑定语义更清晰)。
/// </summary>
public ObservableCollection<BoardProcessRecord> RecentProcessRecords => RecentBoards;
/// <summary>
/// 获取或设置主窗口标题。
/// </summary>
[ObservableProperty]
private string _title = string.Empty;
/// <summary>
/// 获取或设置 PLC 状态文本。
/// </summary>
[ObservableProperty]
private string _plcStatus = "未连接";
/// <summary>
/// 获取或设置扫码枪状态文本。
/// </summary>
[ObservableProperty]
private string _scannerStatus = "未验证";
/// <summary>
/// 获取或设置 SFTP 状态文本。
/// </summary>
[ObservableProperty]
private string _sftpStatus = "未验证";
/// <summary>
/// 获取或设置安灯状态文本。
/// </summary>
[ObservableProperty]
private string _andonStatus = "未验证";
/// <summary>
/// 获取或设置当前流程状态文本。
/// </summary>
[ObservableProperty]
private string _workflowStateText = "空闲等待";
/// <summary>
/// 获取或设置当前条码。
/// </summary>
[ObservableProperty]
private string _currentBarcode = string.Empty;
/// <summary>
/// 获取或设置当前结果描述。
/// </summary>
[ObservableProperty]
private string _resultDescription = "等待触发";
/// <summary>
/// 获取或设置当前故障信息。
/// </summary>
[ObservableProperty]
private string _faultMessage = string.Empty;
/// <summary>
/// 获取或设置扫码次数。
/// </summary>
[ObservableProperty]
private int _scanTryCount;
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
[ObservableProperty]
private int _sftpTryCount;
/// <summary>
/// 获取或设置结果代码。
/// </summary>
[ObservableProperty]
private ushort _resultCode;
/// <summary>
/// 获取或设置报警代码。
/// </summary>
[ObservableProperty]
private ushort _alarmCode;
/// <summary>
/// 获取或设置最近触发时间。
/// </summary>
[ObservableProperty]
private string _lastTriggeredAt = "-";
/// <summary>
/// 获取或设置最近完成时间。
/// </summary>
[ObservableProperty]
private string _lastCompletedAt = "-";
/// <summary>
/// 获取或设置是否忙碌。
/// </summary>
[ObservableProperty]
private bool _isBusy;
/// <summary>
/// 获取或设置是否存在系统故障。
/// </summary>
[ObservableProperty]
private bool _isFaulted;
/// <summary>
/// 获取或设置是否已完成。
/// </summary>
[ObservableProperty]
private bool _isDone;
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
[ObservableProperty]
private bool _isAlarmRaised;
/// <summary>
/// 获取或设置最近更新时间。
/// </summary>
[ObservableProperty]
private string _lastUpdatedAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
/// <summary>
/// 获取或设置运行日志区的最后刷新时间文本。
/// </summary>
[ObservableProperty]
private string _lastLogUpdateTime = "-";
/// <summary>
/// 获取或设置今日异常日志条数。
/// </summary>
[ObservableProperty]
private int _todayErrorCount;
/// <summary>
/// 获取或设置当前活跃告警数量。
/// </summary>
[ObservableProperty]
private int _activeAlarmCount;
/// <summary>
/// 获取或设置最近一次异常日志的时间文本。
/// </summary>
[ObservableProperty]
private string _lastErrorTime = "-";
/// <summary>
/// 获取或设置当前是否存在处理记录,供空状态显示切换使用。
/// </summary>
[ObservableProperty]
private bool _hasProcessRecords;
/// <summary>
/// 获取或设置今日处理总数。
/// </summary>
[ObservableProperty]
private int _todayProcessCount;
/// <summary>
/// 获取或设置今日 OK 数。
/// </summary>
[ObservableProperty]
private int _todayOkCount;
/// <summary>
/// 获取或设置今日 NG 数。
/// </summary>
[ObservableProperty]
private int _todayNgCount;
/// <summary>
/// 获取或设置处理记录区的最后刷新时间文本。
/// </summary>
[ObservableProperty]
private string _lastProcessUpdateTime = "-";
/// <summary>
/// 执行手动复位命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ResetAsync()
{
return _workflowControlService.ResetAsync(CancellationToken.None);
}
/// <summary>
/// 执行 PLC 重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ReconnectPlcAsync()
{
return _workflowControlService.ReconnectPlcAsync(CancellationToken.None);
}
/// <summary>
/// 执行扫码枪重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ReconnectScannerAsync()
{
return _workflowControlService.ReconnectScannerAsync(CancellationToken.None);
}
/// <summary>
/// 执行安灯测试命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task TestAndonAsync()
{
return _workflowControlService.TestAndonAsync(CancellationToken.None);
}
/// <summary>
/// 查看全部运行日志命令占位。由工具栏触发,暂时只刷新统计与时间戳。
/// </summary>
[RelayCommand]
private void ShowAllLogs()
{
RecalculateLogStatistics();
}
/// <summary>
/// 仅显示错误日志命令占位。后续可接入筛选视图,当前刷新统计与时间戳。
/// </summary>
[RelayCommand]
private void ShowErrorLogs()
{
RecalculateLogStatistics();
}
/// <summary>
/// 导出运行日志命令占位。后续可接入日志文件导出逻辑。
/// </summary>
[RelayCommand]
private void ExportLogs()
{
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
/// </summary>
[RelayCommand]
private void OpenTrackingArea()
{
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 刷新处理记录命令占位。重新计算统计并更新最后刷新时间。
/// </summary>
[RelayCommand]
private void RefreshProcessRecords()
{
RecalculateProcessStatistics();
}
/// <summary>
/// 将快照应用到当前视图模型状态。
/// </summary>
/// <param name="snapshot">运行态快照。</param>
private void ApplySnapshot(RuntimeSnapshot snapshot)
{
PlcStatus = snapshot.PlcStatus;
ScannerStatus = snapshot.ScannerStatus;
SftpStatus = snapshot.SftpStatus;
AndonStatus = snapshot.AndonStatus;
WorkflowStateText = snapshot.WorkflowStateText;
CurrentBarcode = snapshot.CurrentBarcode;
ResultDescription = snapshot.ResultDescription;
FaultMessage = snapshot.FaultMessage;
ScanTryCount = snapshot.ScanTryCount;
SftpTryCount = snapshot.SftpTryCount;
ResultCode = snapshot.ResultCode;
AlarmCode = snapshot.AlarmCode;
LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
IsBusy = snapshot.IsBusy;
IsFaulted = snapshot.SystemFault;
IsDone = snapshot.ProcessDone;
IsAlarmRaised = snapshot.AlarmRaised;
ActiveAlarmCount = snapshot.AlarmRaised ? 1 : 0;
LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 当 <see cref="IsAlarmRaised"/> 发生变化时同步活跃告警计数。
/// </summary>
/// <param name="value">最新告警状态。</param>
partial void OnIsAlarmRaisedChanged(bool value)
{
ActiveAlarmCount = value ? 1 : 0;
}
/// <summary>
/// 处理日志集合变化事件,刷新日志区统计字段。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">集合变化参数。</param>
private void OnLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RecalculateLogStatistics();
}
/// <summary>
/// 处理处理记录集合变化事件,刷新处理区统计字段。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">集合变化参数。</param>
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RecalculateProcessStatistics();
}
/// <summary>
/// 根据当前日志集合重新计算日志区的统计信息与最后刷新时间。
/// </summary>
private void RecalculateLogStatistics()
{
DateTime today = DateTime.Today;
int errorToday = 0;
DateTimeOffset? lastErrorAt = null;
foreach (UiLogEntry entry in Logs)
{
bool isError = string.Equals(entry.Level, "Error", StringComparison.OrdinalIgnoreCase);
if (!isError)
{
continue;
}
if (entry.Timestamp.LocalDateTime.Date == today)
{
errorToday++;
}
if (lastErrorAt is null || entry.Timestamp > lastErrorAt.Value)
{
lastErrorAt = entry.Timestamp;
}
}
TodayErrorCount = errorToday;
LastErrorTime = lastErrorAt.HasValue
? lastErrorAt.Value.ToString("MM-dd HH:mm:ss")
: "-";
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 根据当前处理记录集合重新计算处理区的统计信息与最后刷新时间。
/// </summary>
private void RecalculateProcessStatistics()
{
DateTime today = DateTime.Today;
int totalToday = 0;
int okToday = 0;
int ngToday = 0;
foreach (BoardProcessRecord record in RecentBoards)
{
if (record.CompletedAt.LocalDateTime.Date != today)
{
continue;
}
totalToday++;
if (record.ResultCode == (ushort)WorkflowResultCode.Passed)
{
okToday++;
}
else if (record.ResultCode != (ushort)WorkflowResultCode.Processing
&& record.ResultCode != (ushort)WorkflowResultCode.None)
{
ngToday++;
}
}
TodayProcessCount = totalToday;
TodayOkCount = okToday;
TodayNgCount = ngToday;
HasProcessRecords = RecentBoards.Count > 0;
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 处理运行态快照变化事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="snapshot">最新快照。</param>
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
{
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
}
/// <summary>
/// 处理新增日志事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="entry">新增日志。</param>
private async void OnLogAdded(object? sender, UiLogEntry entry)
{
await _dispatcherService.InvokeAsync(() =>
{
Logs.Insert(0, entry);
while (Logs.Count > _workflowOptions.MaxUiLogEntries)
{
Logs.RemoveAt(Logs.Count - 1);
}
}).ConfigureAwait(false);
}
/// <summary>
/// 处理新增单板记录事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="record">新增单板记录。</param>
private async void OnRecordAdded(object? sender, BoardProcessRecord record)
{
await _dispatcherService.InvokeAsync(() =>
{
RecentBoards.Insert(0, record);
while (RecentBoards.Count > _workflowOptions.MaxBoardRecords)
{
RecentBoards.RemoveAt(RecentBoards.Count - 1);
}
}).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,80 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.ViewModels;
/// <summary>
/// 提供系统配置编辑、保存与重载能力。
/// </summary>
public partial class SystemSettingViewModel : ObservableObject
{
private readonly IAppConfigService _appConfigService;
/// <summary>
/// 初始化系统设置视图模型。
/// </summary>
/// <param name="appConfigService">配置读写服务。</param>
public SystemSettingViewModel(IAppConfigService appConfigService)
{
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
EditableConfig = _appConfigService.Load();
ConfigPath = _appConfigService.GetConfigPath();
StatusMessage = "已加载配置。";
}
/// <summary>
/// 获取或设置可编辑配置对象。
/// </summary>
[ObservableProperty]
private AppConfig _editableConfig = new();
/// <summary>
/// 获取或设置状态文本。
/// </summary>
[ObservableProperty]
private string _statusMessage = string.Empty;
/// <summary>
/// 获取或设置配置文件路径。
/// </summary>
[ObservableProperty]
private string _configPath = string.Empty;
/// <summary>
/// 保存当前配置。
/// </summary>
private void SaveConfig()
{
_appConfigService.Save(EditableConfig);
StatusMessage = "配置已保存,重启应用后完全生效。";
}
/// <summary>
/// 重新加载配置文件。
/// </summary>
private void ReloadConfig()
{
EditableConfig = _appConfigService.Load();
StatusMessage = "配置已重新加载。";
}
/// <summary>
/// 保存配置命令。
/// </summary>
[RelayCommand]
private void Save()
{
SaveConfig();
}
/// <summary>
/// 重载配置命令。
/// </summary>
[RelayCommand]
private void Reload()
{
ReloadConfig();
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
namespace AxiOmron.PcbCheck.ViewModels;
/// <summary>
/// 为 XAML 提供视图模型定位能力。
/// </summary>
public class ViewModelLocator
{
/// <summary>
/// 获取主窗口视图模型。
/// </summary>
public MainWindowViewModel MainWindowViewModel
=> App.Services.GetRequiredService<MainWindowViewModel>();
/// <summary>
/// 获取系统设置视图模型。
/// </summary>
public SystemSettingViewModel SystemSettingViewModel
=> App.Services.GetRequiredService<SystemSettingViewModel>();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
using System.Windows.Controls;
namespace AxiOmron.PcbCheck.Views.Pages;
/// <summary>
/// 表示系统运行总览页。
/// </summary>
public partial class DashboardPage : Page
{
/// <summary>
/// 初始化总览页。
/// </summary>
public DashboardPage()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,100 @@
<Page x:Class="AxiOmron.PcbCheck.Views.Pages.SystemSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
mc:Ignorable="d"
d:DesignWidth="1400"
d:DesignHeight="860"
Title="SystemSettingsPage"
d:DataContext="{Binding Source={StaticResource DesignTimeLocator}, Path=SystemSettingViewModel}"
DataContext="{Binding Source={StaticResource Locator}, Path=SystemSettingViewModel}">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TabControl Grid.Row="0" Style="{StaticResource TabControlInLine}">
<TabItem Header="PLC">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="PLC 主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Host, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Port, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="从站号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.UnitId, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="轮询周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.PollIntervalMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="心跳周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.HeartbeatIntervalMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行脉冲(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleasePulseMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行应答超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleaseAckTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="扫码枪">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="串口号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.PortName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="波特率" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.BaudRate, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="数据位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.DataBits, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="校验位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Parity, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="停止位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.StopBits, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ReadTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="触发命令" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.TriggerCommand, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="返回结束符" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ResponseTerminator, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="SFTP">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="用户名" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Username, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="密码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Password, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="私钥路径" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.PrivateKeyPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RootPath, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="安灯 &amp; 流程">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<CheckBox Content="启用安灯接口" IsChecked="{Binding EditableConfig.Andon.Enable}" Margin="0,6,0,0" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯 URL" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Url, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="请求方法" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Method, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.TimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="工位编码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.StationCode, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="工位名称" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.StationName, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox Content="扫码失败时报警" IsChecked="{Binding EditableConfig.Andon.EnableScanFailAlarm}" Margin="0,6,0,0" />
<CheckBox Content="文件未找到时报警" IsChecked="{Binding EditableConfig.Andon.EnableFileNotFoundAlarm}" Margin="0,6,0,0" />
<CheckBox Content="要求 PLC Ready" IsChecked="{Binding EditableConfig.Workflow.RequirePlcReady}" Margin="0,6,0,0" />
<CheckBox Content="要求 AutoMode &amp; StationEnable" IsChecked="{Binding EditableConfig.Workflow.RequireAutoMode}" Margin="0,6,0,0" />
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
<Grid Grid.Row="1" Margin="0,16,0,0" MinHeight="60">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Vertical" VerticalAlignment="Center">
<TextBlock Text="{Binding StatusMessage}" FontWeight="SemiBold" />
<TextBlock Margin="0,4,0,0" Foreground="#A33A00" Text="保存后需重启应用生效。" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource ButtonDefault}" Margin="0,0,8,0" Padding="18,8" MinHeight="36" Content="重新加载" Command="{Binding ReloadCommand}" />
<Button Style="{StaticResource ButtonPrimary}" Padding="18,8" MinHeight="36" Content="保存配置" Command="{Binding SaveCommand}" />
</StackPanel>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,17 @@
using System.Windows.Controls;
namespace AxiOmron.PcbCheck.Views.Pages;
/// <summary>
/// 表示系统配置页面。
/// </summary>
public partial class SystemSettingsPage : Page
{
/// <summary>
/// 初始化系统配置页面。
/// </summary>
public SystemSettingsPage()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,15 @@
{
"Plc": {
"Host": "127.0.0.1"
},
"Scanner": {
"PortName": "COM3"
},
"Sftp": {
"Host": "127.0.0.1",
"RootPath": "/tmp/pcb"
},
"Andon": {
"Url": "http://127.0.0.1:5000/api/andon/test"
}
}

View File

@@ -0,0 +1,84 @@
{
"Plc": {
"Host": "127.0.0.1",
"Port": 502,
"UnitId": 1,
"PollIntervalMs": 200,
"ConnectTimeoutMs": 3000,
"HeartbeatIntervalMs": 500,
"ReleasePulseMs": 500,
"ReleaseAckTimeoutMs": 2000,
"Inputs": {
"PlcReady": 10001,
"PcbArrived": 10002,
"PlcReset": 10003,
"PlcAckRelease": 10004,
"AutoMode": 10005,
"StationEnable": 10006
},
"Outputs": {
"PcOnline": 51,
"PcBusy": 52,
"ScanOk": 53,
"ScanNg": 54,
"FileFound": 55,
"FileNotFound": 56,
"AlarmRaised": 57,
"ReleasePermit": 58,
"ProcessDone": 59,
"SystemFault": 60
},
"Registers": {
"ResultCode": 40001,
"ScanTryCount": 40002,
"SftpTryCount": 40003,
"AlarmCode": 40004,
"FlowStateCode": 40005
}
},
"Scanner": {
"PortName": "COM1",
"BaudRate": 9600,
"DataBits": 8,
"Parity": "None",
"StopBits": "One",
"ReadTimeoutMs": 3000,
"TriggerCommand": "SCAN\\r",
"ResponseTerminator": "\\r",
"MaxScanAttempts": 3
},
"Sftp": {
"Host": "127.0.0.1",
"Port": 22,
"Username": "user",
"Password": "",
"PrivateKeyPath": "",
"PrivateKeyPassphrase": "",
"RootPath": "/pcb",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
"ConnectTimeoutMs": 3000
},
"Andon": {
"Enable": true,
"Url": "http://127.0.0.1:5000/api/andon",
"Method": "POST",
"TimeoutMs": 3000,
"StationCode": "OMRON-01",
"StationName": "PCB目检工位",
"EnableScanFailAlarm": true,
"EnableFileNotFoundAlarm": false,
"Headers": {
"Content-Type": "application/json"
}
},
"Workflow": {
"RequirePlcReady": true,
"RequireAutoMode": true,
"RequireStationEnable": true,
"RequireManualResetAfterFault": true,
"MaxUiLogEntries": 200,
"MaxBoardRecords": 100
}
}