✨ feat: 初始化 PCB 检测 WPF 应用程序
* 创建 AxiOmron.PcbCheck 项目主框架及解决方案 * 添加 Dashboard 和系统设置页面 * 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务 * 集成 Andon 报警、工作流托管服务与日志配置 * 补充项目文档和 UI 设计规范
This commit is contained in:
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal 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
58
.editorconfig
Normal 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
19
.gitattributes
vendored
Normal 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
64
.gitignore
vendored
Normal 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
248
AGENTS.md
Normal 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
|
||||||
|
- UI:WPF
|
||||||
|
- 项目文件: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` 或环境变量
|
||||||
|
- 测试:优先单独测试项目,推荐 xUnit;UI 难测逻辑应下沉到 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 或 Service,code-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
2
AxiOmron.PcbCheck.slnx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<Solution>
|
||||||
|
</Solution>
|
||||||
897
docs/2026-04-16-pcb-check-flow-design.md
Normal file
897
docs/2026-04-16-pcb-check-flow-design.md
Normal 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
|
||||||
|
- PLC:Modbus 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. 主界面状态展示
|
||||||
@@ -39,6 +39,64 @@
|
|||||||
- 列宽与绑定不变。
|
- 列宽与绑定不变。
|
||||||
- **流程状态区**:将“流程状态”卡片改为 `hc:Card` 高亮展示,错误信息使用红色文字(不变)。
|
- **流程状态区**:将“流程状态”卡片改为 `hc:Card` 高亮展示,错误信息使用红色文字(不变)。
|
||||||
|
|
||||||
|
#### Dashboard 下半区布局方案确认
|
||||||
|
|
||||||
|
- 已确认采用 **方案 A:双栏中枢布局**。
|
||||||
|
- 设计目标:
|
||||||
|
- 消除当前页面下半区的大面积空白。
|
||||||
|
- 强化“当前板状态”和“过程追踪”两个核心视觉中心。
|
||||||
|
- 将运行日志降为辅助信息,而不是和主业务区争抢注意力。
|
||||||
|
- 布局原则:
|
||||||
|
- 左侧承担“当前状态 + 运行日志”主视角,右侧承担“处理记录 + 追踪摘要”副视角。
|
||||||
|
- 下半区不再平均切成多个小卡片,而是改为“少块、大面、强层级”。
|
||||||
|
- 通过不同卡片高度、标题层级、留白和分组,建立更清晰的信息优先级。
|
||||||
|
|
||||||
|
#### Dashboard 下半区具体布局
|
||||||
|
|
||||||
|
- **整体分栏**:
|
||||||
|
- 下半区采用 `1.3 : 0.9` 左右双栏。
|
||||||
|
- 左栏为主工作区,右栏为过程追踪区。
|
||||||
|
- **左栏上部**:
|
||||||
|
- 使用两个并排摘要卡片。
|
||||||
|
- 卡片 1:`当前二维码 + 结果码 / 报警码`
|
||||||
|
- 卡片 2:`关键标志 + 扫码次数 + SFTP 次数`
|
||||||
|
- 当前二维码信息作为左栏上部的第一视觉重点,字体明显大于普通统计值。
|
||||||
|
- **左栏下部**:
|
||||||
|
- 放置 `最近运行日志`,占据左栏主要高度。
|
||||||
|
- DataGrid 保留,但应提升表头、行高、内边距和空状态表现,避免“开发态表格感”过强。
|
||||||
|
- **右栏上部**:
|
||||||
|
- 放置 `最近处理记录摘要`,展示:
|
||||||
|
- 最近触发时间
|
||||||
|
- 最近完成时间
|
||||||
|
- 最后刷新时间
|
||||||
|
- 该区域应做成信息摘要卡,而不是散落的三行文字。
|
||||||
|
- **右栏下部**:
|
||||||
|
- 放置 `最近处理记录列表`。
|
||||||
|
- 列表可以保留 DataGrid,但视觉上应更轻,重点突出时间、条码、结果三列。
|
||||||
|
- 若 HandyControl 样式允许,可适当弱化网格线,提升卡片式整洁感。
|
||||||
|
|
||||||
|
#### Dashboard 下半区视觉风格约束
|
||||||
|
|
||||||
|
- 不新增花哨装饰,不改为互联网运营后台风格,保持工业桌面应用的稳重感。
|
||||||
|
- 卡片层次应依靠以下手段建立,而不是依赖过多边框:
|
||||||
|
- 标题字号差异
|
||||||
|
- 数值字号差异
|
||||||
|
- 卡片阴影和圆角
|
||||||
|
- 区块留白
|
||||||
|
- 轻量分隔
|
||||||
|
- `关键标志` 建议改为更有秩序的纵向状态列表,保留圆点语义,但需统一间距、字号、对齐。
|
||||||
|
- `结果码 / 报警码` 不再作为独立弱卡片存在,应并入主摘要区,提高信息密度。
|
||||||
|
- `最近处理记录` 与 `最近运行日志` 必须形成主次关系:
|
||||||
|
- 主日志区更宽,更适合排查问题。
|
||||||
|
- 处理记录区更紧凑,更适合回看节拍与结果。
|
||||||
|
|
||||||
|
#### 本次改造范围
|
||||||
|
|
||||||
|
- 仅重构 `DashboardPage.xaml` 的下半区布局与视觉层级。
|
||||||
|
- 不修改 ViewModel 属性命名、命令绑定和业务逻辑。
|
||||||
|
- 不新增复杂动画、主题切换、图表控件或第三方可视化组件。
|
||||||
|
- 若需要新增局部样式或页面资源,应优先放在页面内,避免污染全局资源。
|
||||||
|
|
||||||
### 4. SystemSettingsPage.xaml
|
### 4. SystemSettingsPage.xaml
|
||||||
- **TabControl**:使用 HandyControl 的 `TabControl` 样式(默认已覆盖),或显式使用 `hc:TabControl`。
|
- **TabControl**:使用 HandyControl 的 `TabControl` 样式(默认已覆盖),或显式使用 `hc:TabControl`。
|
||||||
- **表单项**:
|
- **表单项**:
|
||||||
@@ -66,3 +124,4 @@
|
|||||||
- 不改写业务逻辑、ViewModel、服务层。
|
- 不改写业务逻辑、ViewModel、服务层。
|
||||||
- 不引入 HandyControl 的高级控件(Growl、Timeline、Pagination 等),避免过度设计。
|
- 不引入 HandyControl 的高级控件(Growl、Timeline、Pagination 等),避免过度设计。
|
||||||
- 不替换为其他 UI 库(MahApps、MaterialDesign、WPFUI 等)。
|
- 不替换为其他 UI 库(MahApps、MaterialDesign、WPFUI 等)。
|
||||||
|
|
||||||
|
|||||||
17
src/AxiOmron.PcbCheck/App.xaml
Normal file
17
src/AxiOmron.PcbCheck/App.xaml
Normal 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>
|
||||||
130
src/AxiOmron.PcbCheck/App.xaml.cs
Normal file
130
src/AxiOmron.PcbCheck/App.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/AxiOmron.PcbCheck/AssemblyInfo.cs
Normal file
10
src/AxiOmron.PcbCheck/AssemblyInfo.cs
Normal 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)
|
||||||
|
)]
|
||||||
33
src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj
Normal file
33
src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj
Normal 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>
|
||||||
61
src/AxiOmron.PcbCheck/Converters/BooleanToBrushConverter.cs
Normal file
61
src/AxiOmron.PcbCheck/Converters/BooleanToBrushConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs
Normal file
120
src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs
Normal file
206
src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/AxiOmron.PcbCheck/MainWindow.xaml
Normal file
38
src/AxiOmron.PcbCheck/MainWindow.xaml
Normal 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>
|
||||||
46
src/AxiOmron.PcbCheck/MainWindow.xaml.cs
Normal file
46
src/AxiOmron.PcbCheck/MainWindow.xaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs
Normal file
138
src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
579
src/AxiOmron.PcbCheck/Models/WorkflowModels.cs
Normal file
579
src/AxiOmron.PcbCheck/Models/WorkflowModels.cs
Normal 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 => "系统故障",
|
||||||
|
_ => "未知状态"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/AxiOmron.PcbCheck/NLog.config
Normal file
16
src/AxiOmron.PcbCheck/NLog.config
Normal 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>
|
||||||
415
src/AxiOmron.PcbCheck/Options/AppConfig.cs
Normal file
415
src/AxiOmron.PcbCheck/Options/AppConfig.cs
Normal 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;
|
||||||
|
}
|
||||||
118
src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
Normal file
118
src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
Normal file
154
src/AxiOmron.PcbCheck/Services/Implementations/AppLogger.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
261
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
Normal file
261
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs
Normal 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);
|
||||||
|
}
|
||||||
135
src/AxiOmron.PcbCheck/Utils/TextAndPatternHelpers.cs
Normal file
135
src/AxiOmron.PcbCheck/Utils/TextAndPatternHelpers.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
505
src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs
Normal file
505
src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs
Normal file
80
src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/AxiOmron.PcbCheck/ViewModels/ViewModelLocator.cs
Normal file
21
src/AxiOmron.PcbCheck/ViewModels/ViewModelLocator.cs
Normal 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>();
|
||||||
|
}
|
||||||
1130
src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml
Normal file
1130
src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml
Normal file
File diff suppressed because it is too large
Load Diff
17
src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml.cs
Normal file
17
src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
Normal file
100
src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
Normal 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="安灯 & 流程">
|
||||||
|
<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 & 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>
|
||||||
17
src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml.cs
Normal file
17
src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/AxiOmron.PcbCheck/appConfig.Development.json
Normal file
15
src/AxiOmron.PcbCheck/appConfig.Development.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/AxiOmron.PcbCheck/appConfig.json
Normal file
84
src/AxiOmron.PcbCheck/appConfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user