Compare commits

...
25 Commits
Author SHA1 Message Date
jimmy 5e9e9fa818 A:删除测试文档 2025-12-28 15:44:06 +08:00
jimmyandClaude Sonnet 4.5 8ca0679fca 移除 Client 和 Server 模块,由 Sharp.Demo 替代
Sharp.Demo 模块已经整合了 Client 和 Server 的所有功能,
并采用了最佳实践(Quick Start API、标准 DI、Result<T> 模式等),
因此 Client 和 Server 模块已不再需要。

变更内容:
- 从解决方案中移除 Client 和 Server 项目
- 更新 CLAUDE.md 文档(根目录和 src/Sharp/)
- 更新项目结构说明,明确 Sharp.Demo 为综合演示应用
- 更新构建和运行命令,指向 Sharp.Demo
- 更新依赖关系说明

Sharp.Demo 优势:
- 使用 Quick Start API 简化配置
- 标准依赖注入(移除 Global 容器依赖)
- Result<T> 模式演示
- IApplicationState 应用状态管理
- 完整的开发者工具集成(F10/F11/F12)
- 交互式模块演示(17+ 功能点)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 15:41:21 +08:00
jimmy 1b5d57bd19 A:日志集成 2025-12-28 15:05:59 +08:00
jimmy f610bf5e34 A:集成配置文件 2025-12-28 13:08:42 +08:00
jimmy 54545aad35 A 2025-12-28 12:59:06 +08:00
jimmy 012c9522f8 1 2025-12-24 22:55:59 +08:00
jimmy 99c2c59552 A:集成数据库功能 2025-12-24 21:16:48 +08:00
jimmy 974d16833c A:增加页面集中管理 2025-12-24 19:36:42 +08:00
jimmy 2943485a2a A:调整西门子PLC数据解析方式 2025-12-23 16:38:26 +08:00
jimmy 30451cabbd A:增加可选页面模块 2025-12-22 21:19:01 +08:00
jimmy 37aa848b5f A:简化HandyControl引用 2025-12-22 20:20:19 +08:00
jimmy 2563288afe A:整体并简化代码 2025-12-22 19:50:09 +08:00
jimmy 2c83fc72e4 A:剩余提交 2025-12-22 16:40:34 +08:00
jimmy dd0681ba90 A:调整页面展示和引入方式 2025-12-16 15:00:53 +08:00
jimmy 4ec76b7855 U:调整调用方式和兼容性 2025-12-15 13:37:11 +08:00
jimmy 433e206d45 A:调整整体README 2025-12-11 16:53:33 +08:00
jimmy 26a2661625 A:开放PLC定时循环参数 2025-12-11 15:12:49 +08:00
jimmy 5b7f127bb5 A:调整数据监听方案 2025-12-11 14:44:58 +08:00
jimmy ea6103a596 A:增加Float格式支持 2025-12-11 14:00:01 +08:00
jimmy de1a29c522 A:增加订阅类型选择 2025-12-10 14:10:13 +08:00
jimmy 6ed0d3f1c2 U:修改PLC监听数据不会更新BUG 2025-12-10 13:37:04 +08:00
jimmy d9e5a5483c A:增加对应的说明文档。增加串口调试页面 2025-12-09 15:31:37 +08:00
jimmy bc09280d4e A:优化PLC数据管理 2025-12-09 13:44:09 +08:00
jimmy 2f3eb935dd A:增加PLC功能和西门子PLC对接 2025-12-09 09:40:50 +08:00
jimmy bca001b90f A:初始提交 2025-12-08 14:05:44 +08:00
269 changed files with 34017 additions and 1541 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Visual Studio
.vs/
bin/
obj/
[Dd]ebug/
[Rr]elease/
*.user
*.suo
*.userprefs
*.usertasks
*.sln.docstates
*.dll
*.pdb
*.exe
!src/**/bin/**/*.dll
!src/**/bin/**/*.pdb
!src/**/bin/**/*.exe
# Rider
.idea/
# Dotnet
project.lock.json
project.fragment.lock.json
artifacts/
# Other
Thumbs.db
Desktop.ini
+226
View File
@@ -0,0 +1,226 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sharp-Basic is a modular C# .NET 8 framework for building enterprise applications with reusable components. It provides a comprehensive toolkit with emphasis on:
- **Modularity**: Independent, reusable libraries that can be used selectively
- **Desktop Applications**: WPF-based UI toolkit with MVVM infrastructure
- **Industrial IoT**: PLC integration (Siemens) and serial port communication
- **Configuration as Code**: Multi-format (JSON/XML/INI/TOML) with hot-reload
- **Functional Programming**: Result<T> pattern for railway-oriented error handling
**Language**: All comments and documentation are in Chinese (中文).
## Build Commands
### Build the entire solution
```bash
cd src/Sharp
dotnet build Sharp.sln
```
### Build specific project
```bash
cd src/Sharp
dotnet build Sharp.Demo/Sharp.Demo.csproj # WPF demo application (最佳实践示例)
dotnet build Common/Common.csproj # Core utilities
dotnet build Config/Config.csproj # Configuration management
```
### Run tests
```bash
cd src/Sharp
dotnet test Sharp.sln # All tests
dotnet test ../../test/Common.Test # Specific test project
```
### Run the Sharp.Demo application
```bash
cd src/Sharp
dotnet run --project Sharp.Demo/Sharp.Demo.csproj
```
### Clean build artifacts
```bash
cd src/Sharp
dotnet clean Sharp.sln
```
## Architecture Overview
### Project Structure
```
src/Sharp/ # All source projects
├── Common/ # Core foundation (Result<T>, Global container, utilities, SystemConfig)
├── Config/ # Multi-format configuration with hot-reload
├── Wpf/ # WPF UI toolkit (MVVM, dialogs, converters) - NO UI library dependency
├── Wpf.HandyControl/ # HandyControl UI library integration (optional)
├── Wpf.DevTools/ # WPF Developer Tools (DevTools, LogViewer, Performance Monitor)
├── Data/ # EF Core wrapper with repository pattern
├── Log/ # Serilog-based structured logging
├── Cache/ # Memory cache (planned)
├── EventBus/ # Event pub/sub (planned)
├── NetClient/ # HTTP client with Polly resilience
├── NetServer/ # Lightweight HTTP server with middleware
├── Ports/ # Serial port abstraction (RS232/RS485)
├── Plc/ # PLC abstraction interface
├── Plc.Siemens/ # Siemens S7 PLC implementation (S7.NET+)
├── Process/ # Process execution
├── Python/ # Python script execution
└── Sharp.Demo/ # 综合演示应用(最佳实践示例,替代了 Client 和 Server)
test/ # MSTest test projects
├── Common.Test/
├── Config.Test/
├── Data.Test/
├── Log.Test/
├── NetClient.Test/
└── NetServer.Test/
appsettings.template.json # 完整的配置模板文件
```
### Key Architectural Patterns
#### 1. Result<T> Pattern (Railway-Oriented Programming)
- Eliminates exceptions as control flow
- Provides chainable operations: `Map`, `Bind`, `Match`, `Where`, `OnSuccess`, `OnFailure`
- Synchronous and async wrappers: `Result.Try()`, `Result.TryAsync()`
- Example usage:
```csharp
Result.Try(() => int.Parse(input))
.Map(x => x * 2)
.Where(v => v > 100, "Value too small")
.OnSuccess(v => Console.WriteLine(v))
.OnFailure((ex, msg) => logger.LogError(msg));
```
#### 2. Dependency Injection (Hybrid Approach)
- **Primary**: Microsoft.Extensions.DependencyInjection for standard DI
- **Fallback**: Global service locator (Common.Global) for legacy/convenience
- Services registered in `DependencyInjection` folders within each module
- Client app startup (App.xaml.cs) orchestrates all service registration
#### 3. Configuration Management
- Multi-source configuration builder supporting JSON/XML/INI/TOML
- Hot-reload with change notifications
- Strong typing with DataAnnotations validation
- IOptions<T> pattern integration
- Environment variable overrides with optional prefix
#### 4. Repository Pattern with EF Core
- Generic `IRepository<TEntity>` abstraction
- Built-in entity types: `Entity`, `AuditableEntity`, `ISoftDeletable`
- Auto-audit tracking (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
- Multi-database support: SQL Server, SQLite, PostgreSQL, MySQL, In-Memory
#### 5. MVVM for WPF
- `ViewModelBase` with INotifyPropertyChanged and IsBusy tracking
- `RelayCommand` and `AsyncCommand` implementations
- Services: `IDialogService`, `IFileDialogService`, `IDispatcherService`
- Converters: BoolToVisibility, InverseBooleanConverter, EnumToDescriptionConverter
- Global exception handling for UI/Task/AppDomain exceptions
#### 6. Middleware Pattern
- Custom HTTP server (NetServer) with ASP.NET Core-style pipeline
- Middleware: Authentication, CORS, Rate Limiting, Caching, Logging, Exception Handling
- Attribute-based routing with `RouteAttribute`
- Custom `IMiddleware` interface for extensibility
## Module Dependencies
**Core Layer** (no external dependencies):
- Common → (no dependencies)
**Configuration Layer**:
- Config → Microsoft.Extensions.Configuration.*, Tomlyn (TOML support)
**Data Layer**:
- Data → EF Core, Common
- Repository → Data
**Network Layer**:
- NetClient → Polly, Microsoft.Extensions.Http, Common
- NetServer → Common, Log
**WPF Layer**:
- Wpf → HandyControl (optional UI controls)
- Client → Wpf, Config, NetClient, NetServer, Log, Plc.Siemens, Ports
**Industrial IoT**:
- Ports → (serial port communication)
- Plc → (abstraction only)
- Plc.Siemens → Plc, S7.NET+ library
**Logging**:
- Log → Serilog, Microsoft.Extensions.Logging
## Common Development Workflows
### Adding a new module/library
1. Create project under `src/Sharp/`
2. Target `net8.0` (or `net8.0-windows` for WPF)
3. Enable nullable reference types: `<Nullable>enable</Nullable>`
4. Create `DependencyInjection` folder for service registration extensions
5. Add test project under `test/` with MSTest
6. Add to Sharp.sln: `dotnet sln add <ProjectPath>`
### Working with Result<T>
- Use `Result.Try()` to wrap exception-prone code
- Chain operations with `Map()` for transformations
- Use `Bind()` for operations that return Result<T>
- Extract values with `GetOrDefault()`, `GetOrThrow()`, or `Match()`
- Always handle both success and failure cases
### Using the Global container
- Register singletons: `Global.RegisterSingleton<T>(instance)` or `Global.RegisterSingleton<T>(() => new T())`
- Register transients: `Global.RegisterTransient<T>(() => new T())`
- Subscribe to changes: `Global.Subscribe<T>(e => { /* handle change */ })`
- Retrieve services: `Global.Get<T>()`
- Prefer standard DI (IServiceCollection) for new code
### Configuration workflow
1. Add config files to project root (appsettings.json, etc.)
2. Create strongly-typed options class with DataAnnotations
3. Build ConfigManager in App.xaml.cs OnStartup
4. Register with DI: `services.AddSingleton(cfg)` and `services.AddSingleton(cfg.Configuration)`
5. Use hot-reload callbacks: `cfg.OnReload(() => { /* refresh */ })`
### WPF Client startup flow
The Client application (App.xaml.cs) orchestrates initialization:
1. Register global exception handlers (UI, Task, AppDomain)
2. Build ConfigManager from appsettings.json
3. Setup ServiceCollection and register all services
4. Create ServiceProvider
5. Show LoginWindow (modal)
6. On successful login, show MainWindow
### Working with PLC (Siemens)
- Interface: `IPLC` in Plc project
- Implementation: Plc.Siemens using S7.NET+ library
- Supports: S7-1200, S7-1500, S7-300, S7-400
- Register with DI: Add Siemens PLC service with connection options
- Methods: Read/Write memory areas, data blocks, etc.
### Serial Port Communication
- Service: `ISerialPortService` in Ports module
- Discovery: `SerialPortDiscovery` for auto-detection
- Event-based data reception: `DataReceivedEventArgs`
- Configured with: baud rate, parity, stop bits, data bits
## Code Style
- **Target Framework**: .NET 8 (net8.0 / net8.0-windows)
- **Nullable**: Enabled (`#nullable enable`)
- **Implicit Usings**: Enabled
- **Namespace Convention**: `Sharp.*` (Common will migrate to `Sharp.Common`)
- **Language**: All comments, docs, and strings in Chinese
- **Backwards Compatibility**: Maintain for minor versions; breaking changes only in major versions
- **Testing**: MSTest with parallel execution enabled
## License
GNU AGPLv3 - Network services that use this framework must open source modifications. Contact author for alternative commercial licensing.
+486
View File
@@ -0,0 +1,486 @@
# Sharp-Basic 配置指南
本文档说明 Sharp-Basic 框架的配置约定和最佳实践。
## 目录
- [配置文件模板](#配置文件模板)
- [约定配置节名称](#约定配置节名称)
- [环境变量覆盖](#环境变量覆盖)
- [模块配置详解](#模块配置详解)
- [最佳实践](#最佳实践)
---
## 配置文件模板
Sharp-Basic 提供了完整的配置文件模板:**`appsettings.template.json`**
### 使用方法
1. **复制模板文件**
```bash
cp appsettings.template.json appsettings.json
```
2. **根据项目需要选择性保留配置**
- 只使用数据库?保留 `Database` 节
- 需要PLC通信?保留 `Plc:Siemens` 节
- 不需要的配置可以删除
3. **修改配置值**
```json
{
"Database": {
"Provider": "SqlServer",
"ConnectionString": "你的数据库连接字符串"
},
"Plc": {
"Siemens": {
"IpAddress": "你的PLC IP地址"
}
}
}
```
---
## 约定配置节名称
Sharp-Basic 遵循 **"约定优于配置"** 原则,每个模块都有固定的配置节名称:
### 核心模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **System** | `System` | 系统级HTTP配置 | `HTTPIp`, `HttpPort` |
| **Database** | `Database` | 数据库连接配置 | `Provider`, `ConnectionString` |
| **Logging** | `Logging` | 日志级别配置 | `LogLevel` |
| **SharpLog** | `SharpLog` | Sharp日志扩展 | 无(全部可选) |
### 网络模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **NetClient** | `NetClient` | HTTP客户端配置 | 无(可使用System默认值) |
| **NetServer** | `NetServer` | HTTP服务端配置 | 无(可使用System默认值) |
### IoT模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **Plc.Siemens** | `Plc:Siemens` | 西门子PLC配置 | `IpAddress`, `CpuType` |
| **SerialPort** | `SerialPort` | 串口通信配置 | `PortName`, `BaudRate` |
### 工具模块
| 模块 | 配置节名称 | 说明 | 必填字段 |
|------|------------|------|----------|
| **Process** | `Process` | 进程执行配置 | 无(全部可选) |
| **Python** | `Python` | Python脚本配置 | 无(全部可选) |
| **Cache** | `Cache` | 缓存配置 | 无(全部可选) |
| **EventBus** | `EventBus` | 事件总线配置 | 无(全部可选) |
---
## 环境变量覆盖
所有配置都可以通过环境变量覆盖,格式:`SHARP__{节名}__{键名}`
### 覆盖规则
- 使用双下划线 `__` 分隔层级
- 所有字母必须大写(Windows不区分大小写,Linux区分)
- 冒号 `:` 用双下划线 `__` 替代
### 示例
#### 覆盖数据库配置
```bash
# Windows (PowerShell)
$env:SHARP__Database__Provider="Sqlite"
$env:SHARP__Database__ConnectionString="Data Source=test.db"
# Linux/macOS
export SHARP__Database__Provider=Sqlite
export SHARP__Database__ConnectionString="Data Source=test.db"
```
#### 覆盖PLC配置
```bash
# Windows (PowerShell)
$env:SHARP__Plc__Siemens__IpAddress="192.168.1.200"
$env:SHARP__Plc__Siemens__CpuType="S71500"
# Linux/macOS
export SHARP__Plc__Siemens__IpAddress=192.168.1.200
export SHARP__Plc__Siemens__CpuType=S71500
```
#### 覆盖System配置
```bash
# Windows (PowerShell)
$env:SHARP__System__HttpPort="9090"
# Linux/macOS
export SHARP__System__HttpPort=9090
```
### 优先级
配置优先级从高到低:
1. **环境变量** - 最高优先级
2. **appsettings.{Environment}.json** - 环境特定配置
3. **appsettings.json** - 默认配置
---
## 模块配置详解
### 1. 数据库配置 (Database)
#### 最简配置
```json
{
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=app.db"
}
}
```
#### 代码中使用
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSharpDatabase<AppDbContext>(config); // 自动读取 Database 节
```
#### 支持的数据库提供程序
- `SqlServer` - Microsoft SQL Server
- `Sqlite` - SQLite
- `PostgreSQL` - PostgreSQL
- `MySql` - MySQL/MariaDB
- `InMemory` - 内存数据库(仅用于测试)
---
### 2. PLC配置 (Plc:Siemens)
#### 最简配置
```json
{
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100",
"CpuType": "S71200"
}
}
}
```
#### 代码中使用
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
```
#### 支持的CPU类型
- `S71200` - S7-1200 系列
- `S71500` - S7-1500 系列
- `S7300` - S7-300 系列
- `S7400` - S7-400 系列
- `S7200` - S7-200 系列
- `S7200Smart` - S7-200 Smart 系列
---
### 3. 日志配置 (Logging & SharpLog)
#### 最简配置
```json
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"SharpLog": {
"LogFilePath": "logs/app-.log",
"RollingInterval": "Day"
}
}
```
#### 代码中使用
```csharp
services.AddLogging(b => b.AddSharpLog()); // 自动读取 SharpLog 节
```
---
### 4. HTTP配置 (System, NetClient, NetServer)
#### System配置(共享默认值)
```json
{
"System": {
"HTTPIp": "127.0.0.1",
"HttpPort": 8080,
"TimeoutSeconds": 30,
"Scheme": "http"
}
}
```
#### NetClient配置(可选,留空则使用System默认值)
```json
{
"NetClient": {
"BaseAddress": "http://api.example.com",
"Timeout": "00:00:30",
"EnableLogging": true
}
}
```
#### NetServer配置(可选,留空则使用System默认值)
```json
{
"NetServer": {
"ListenUrls": [
"http://localhost:8080/"
]
}
}
```
---
## 最佳实践
### 1. 按需配置
**只保留需要的配置节**,不要保留所有模板内容:
```json
{
// ❌ 不好:保留了不使用的配置
"Database": { ... },
"Plc": { ... },
"SerialPort": { ... },
"Python": { ... }
}
{
// ✅ 好:只保留需要的配置
"Database": { ... },
"Logging": { ... }
}
```
### 2. 使用环境特定配置
为不同环境创建不同的配置文件:
```
appsettings.json # 默认配置
appsettings.Development.json # 开发环境
appsettings.Production.json # 生产环境
appsettings.Testing.json # 测试环境
```
### 3. 敏感信息使用环境变量
**不要在配置文件中存储敏感信息**(密码、密钥等):
```bash
# ✅ 好:使用环境变量
export SHARP__Database__ConnectionString="Server=prod;User=sa;Password=SecretPass123"
export SHARP__Plc__Siemens__IpAddress="192.168.1.100"
```
```json
// ❌ 不好:敏感信息写在配置文件
{
"Database": {
"ConnectionString": "Server=prod;User=sa;Password=SecretPass123"
}
}
```
### 4. 添加注释说明
在配置文件中添加注释(JSON5格式,或删除注释后部署):
```json
{
// 数据库配置 - 生产环境使用SQL Server
"Database": {
"Provider": "SqlServer",
"ConnectionString": "Server=.;Database=MyApp;..."
},
// PLC配置 - 车间1号PLC
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100", // 车间1号PLC
"CpuType": "S71200"
}
}
}
```
### 5. 配置验证
Sharp-Basic 会在启动时验证必填配置项:
```csharp
// ✅ 好:配置缺失时会抛出清晰的异常
services.AddSharpDatabase<AppDbContext>(config);
// 异常:未找到数据库连接字符串配置: Database:ConnectionString
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
// 异常:未配置PLC IP地址
```
---
## 快速参考
### 典型WPF应用配置
```json
{
"System": {
"HTTPIp": "127.0.0.1",
"HttpPort": 8080
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=app.db"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"SharpLog": {
"LogFilePath": "logs/app-.log",
"RollingInterval": "Day"
}
}
```
### 工业IoT应用配置
```json
{
"System": {
"HTTPIp": "0.0.0.0",
"HttpPort": 8080
},
"Database": {
"Provider": "SqlServer",
"ConnectionString": "Server=.;Database=ScadaDB;Trusted_Connection=True;"
},
"Plc": {
"Siemens": {
"IpAddress": "192.168.1.100",
"CpuType": "S71200",
"MonitorInterval": 500,
"AutoReconnect": true
}
},
"SerialPort": {
"PortName": "COM1",
"BaudRate": 9600
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Plc.Siemens": "Debug"
}
}
}
```
### Web服务应用配置
```json
{
"System": {
"HTTPIp": "0.0.0.0",
"HttpPort": 5000,
"Scheme": "https"
},
"Database": {
"Provider": "PostgreSQL",
"ConnectionString": "Host=localhost;Database=myapi;Username=postgres;Password=***"
},
"NetServer": {
"ListenUrls": [
"https://0.0.0.0:5000/",
"http://0.0.0.0:5001/"
],
"ShowDetailedErrors": false
},
"Cache": {
"DefaultExpirationSeconds": 300,
"SizeLimit": 10000
}
}
```
---
## 相关文档
- [appsettings.template.json](appsettings.template.json) - 完整配置模板
- [Config模块README](src/Sharp/Config/README.md) - 配置管理详解
- [Data模块README](src/Sharp/Data/README.md) - 数据库配置详解
- [Plc.Siemens模块README](src/Sharp/Plc.Siemens/README.md) - PLC配置详解
---
## 故障排查
### 问题:找不到配置节
**错误信息**:
```
未找到数据库连接字符串配置: Database:ConnectionString
```
**解决方法**:
1. 检查配置文件名是否为 `appsettings.json`
2. 检查配置节名称是否正确(区分大小写)
3. 检查配置文件是否在项目根目录
4. 检查配置文件的 "复制到输出目录" 设置
### 问题:环境变量不生效
**原因**:环境变量格式错误
**正确格式**:
```bash
# ✅ 正确:双下划线分隔
SHARP__Database__Provider=Sqlite
# ❌ 错误:单下划线
SHARP_Database_Provider=Sqlite
# ❌ 错误:使用冒号
SHARP:Database:Provider=Sqlite
```
### 问题:配置被覆盖
**检查优先级**:
1. 先检查环境变量
2. 再检查 appsettings.{Environment}.json
3. 最后检查 appsettings.json
---
**版本**: 1.0.0
**更新日期**: 2025-12-22
+59 -265
View File
@@ -1,287 +1,81 @@
# sharp-basic
# Sharp Basic
C# 公共基础核心(模块化、多项目可复用)
一个基于 .NET 8 的现代化工业自动化与企业级应用开发套件,采用模块化架构设计,提供了从底层通信、基础设施到上层 UI 的完整解决方案。
sharp-basic 旨在将常用基础能力拆分为可独立复用的模块(库项目),帮助业务项目即取即用,减少样板代码与重复实现。
## 🏗️ 架构概览
当前仓库采用多项目结构,包含 Common、Config 等核心模块,以及 Logging、EventBus、NetClient/NetServer 等待实现或扩展的模块,同时提供 WPF/Client 示例项目便于演示与调试。
本项目包含以下核心模块,旨在实现高内聚、低耦合的开发体验:
## 功能概览
### 🔌 核心基础设施
- **[Sharp.Common](Common/README.md)**: 基础工具库。提供轻量级 DI 容器 (`Global`)、函数式结果模式 (`Result<T>`)、以及丰富的扩展方法(String, DateTime, Encryption, Reflection)。
- **[Sharp.Config](Config/README.md)**: 强大的配置管理。支持 JSON/XML/INI/TOML 多源聚合、强类型绑定和数据注解验证。
- **[Sharp.Log](Log/README.md)**: 统一日志门面。基于 Serilog 封装,支持一行代码接入,提供结构化日志和文件滚动策略。
- **[Sharp.EventBus](EventBus/README.md)**: (开发中) 轻量级事件总线。
- 通用工具(Sharp.Common)
- 结果类型 Result/Result<T>:统一成功/失败表达、错误码/异常、Map/Bind/LINQ 风格链式操作、同步/异步 Try 包装
- 轻量服务容器 Global:线程安全注册与获取(Singleton/Transient)、实例变更通知(订阅发布)、批量管理
- 实用工具与扩展:JSON 序列化选项与 IPAddress 转换、端口探测、管理员权限检测、字符串解析等
- 配置中心(Sharp.Config)
- 统一构建器:支持 Json/Ini/Xml/Toml 多源叠加、可选/必选、基路径/文件提供器、环境变量、热重载
- 强类型绑定与验证:DataAnnotations 递归验证、TryGet/异常详情、对象保存与读取(多格式序列化)
- 扩展钩子:自定义 IConfigurationBuilder 管道(ConfigureBuilderHook)
- 日志(Sharp.Log)【规划中】
- 基于 Microsoft.Extensions.Logging 的统一抽象,适配 Console/File/Serilog/NLog,结构化日志与上下文
- 事件总线(Sharp.EventBus)【规划中】
- 进程内发布订阅、异步/顺序/粘性事件,支持 WPF 弱订阅;外部 Broker(Redis/RabbitMQ/Kafka)适配扩展
- 网络(Sharp.NetClient / Sharp.NetServer)【规划中】
- HttpClientFactory 封装、重试熔断超时、请求签名、统一 Result<T> 返回;最小 API Host 与中间件
- 桌面工具(Sharp.Wpf / Sharp.Client)
- WPF 示例/样板,用于演示配置热更、事件订阅、日志等集成的参考
### 💾 数据与存储
- **[Sharp.Data](Data/README.md)**: 极简数据访问层。基于 EF Core,提供开箱即用的仓储模式、自动审计(创建人/时间)、软删除和分页支持。
- **[Sharp.Repository](Repository/README.md)**: 数据库集成模块。通过配置切换 SQL Server/MySQL/PostgreSQL/Sqlite,支持自动迁移。
## 目录结构
### 🌐 网络与通信
- **[Sharp.NetServer](NetServer/README.md)**: 轻量级 Web 框架。基于 `HttpListener`,提供类似 ASP.NET Core 的 Controller/Route/Middleware 开发体验,适合嵌入式或桌面应用内嵌 Web 服务。
- **[Sharp.NetClient](NetClient/README.md)**: 强类型 HTTP 客户端。集成 Polly 重试策略、Bearer Token 认证和 multipart 上传。
- **[Sharp.Ports](Ports/README.md)**: 串口通信库。提供串口发现、连接管理、数据监听和诊断工具。
```
src/Sharp/
Common/ # 通用能力(Result、Global、Utils、扩展)
Config/ # 配置集中管理与序列化
Log/ # 日志抽象(规划中)
EventBus/ # 事件总线(规划中)
NetClient/ # 客户端网络(规划中)
NetServer/ # 服务端网络(规划中)
Wpf/ # WPF 示例
Client/ # WPF 示例(另一个样板)
test/
Common.Test/ # Common 核心用例(MSTest)
### 🏭 工业互联 (IIoT)
- **[Sharp.Plc](Plc/README.md)**: PLC 通信抽象层。定义了统一的读写、订阅接口。
- **[Sharp.Plc.Siemens](Plc.Siemens/README.md)**: 西门子驱动实现。支持 S7-200/300/400/1200/1500,内置智能批量读取优化算法(自动合并相邻地址请求)。
### 🖥️ 桌面应用 UI
- **[Sharp.Wpf](Wpf/README.md)**: WPF 基础库。提供 MVVM 基类、全局异常捕获、常用转换器、对话框服务和行为扩展。
- **[Sharp.Client](Client/README.md)**: 综合客户端示例。集成 PLC 监控、串口调试和用户登录功能的工业控制台应用。
- **[Sharp.Server](Server/README.md)**: 服务器控制台示例。展示了如何在一个 WPF 应用中托管 NetServer Web 服务,并提供可视化管理界面。
## 🚀 快速开始
### 环境要求
- .NET 8.0 SDK
- Visual Studio 2022 或 VS Code
### 构建项目
```bash
# 还原依赖
dotnet restore
# 编译解决方案
dotnet build Sharp.sln -c Debug
```
目标框架:.NET 8(net8.0 / net8.0-windows)。
### 运行示例
## 快速开始
> 当前未发布 NuGet 包,可在解决方案内直接引用相关项目,或将模块作为子模块引入你的解决方案。
### 使用 Result 统一返回
```csharp
using Common.Results;
Result<int> r1 = Result.Success(123);
Result r2 = Result.Failure("something wrong");
// Try 模式(同步/异步)
var r3 = Result.Try(() => int.Parse("456"));
var r4 = await Result.TryAsync(async () => { await Task.Delay(10); return 789; });
// Map/Bind 链式管道
var r5 = r3.Map(x => x * 2).Where(v => v > 500, "too small");
// 提取值(或异常)
int value = r5.GetOrDefault(0); // 成功取值,否则默认
int must = r5.GetOrThrow(); // 失败抛出异常
**1. 运行 WPF 客户端:**
```bash
dotnet run --project Client/Client.csproj
```
### 使用 Global 注册与订阅
```csharp
using Common;
// 注册单例(实例/工厂),与瞬态
Global.RegisterSingleton(new AppConfig { AppName = "MyApp", Version = "1.0" });
Global.RegisterSingleton(() => new CacheService());
Global.RegisterTransient(() => new RequestContext());
// 订阅服务变更
Global.Subscribe<AppConfig>(e =>
{
Console.WriteLine($"Config changed: {e.OldValue?.Version} -> {e.NewValue.Version}");
});
// 更新将触发订阅回调
Global.Update(new AppConfig { AppName = "MyApp", Version = "1.1" });
// 获取
var config = Global.Get<AppConfig>();
**2. 运行嵌入式 Web 服务器:**
```bash
dotnet run --project Server/Server.csproj
```
示例类型(自定义)
```csharp
public sealed class AppConfig { public string AppName { get; set; } = ""; public string Version { get; set; } = ""; }
public sealed class CacheService { }
public sealed class RequestContext { public string RequestId { get; set; } = Guid.NewGuid().ToString(); }
**3. 运行 NetServer 纯控制台示例:**
```bash
dotnet run --project NetServer/Examples
```
### 配置管理(多源叠加/强类型/验证/保存)
## 🧪 测试
```csharp
using Sharp.Config;
// 1) 从多个文件构建(支持 Json/Ini/Xml/Toml),并启用环境变量与热重载
var cfg = ConfigBuilder.Build(opt =>
{
opt.BasePath = AppContext.BaseDirectory;
opt.ReloadOnChange = true;
opt.IncludeEnvironmentVariables = true;
opt.EnvironmentPrefix = "MYAPP_"; // 可选
opt.Sources.Add(new ConfigSource { Path = "appsettings.json", Format = ConfigFormat.Json, Optional = false });
opt.Sources.Add(new ConfigSource { Path = "appsettings.Development.json", Format = ConfigFormat.Json, Optional = true });
});
// 2) 强类型绑定 + 验证(DataAnnotations)
var options = cfg.Get<MyOptions>(validate: true);
// 3) 热重载回调
using var _ = cfg.OnReload(() => Console.WriteLine("Configuration reloaded"));
// 4) 保存/读取对象(跨格式序列化)
cfg.Save(options, path: "out/appsettings.toml", format: ConfigFormat.Toml);
var reloaded = cfg.LoadFromFile<MyOptions>("out/appsettings.toml", ConfigFormat.Toml);
public sealed class MyOptions
{
[System.ComponentModel.DataAnnotations.Required]
public string Name { get; set; } = string.Empty;
public int Port { get; set; } = 8080;
}
```
示例 appsettings.json
```json
{
"name": "demo",
"port": 5000
}
```
## 测试
- 测试框架:MSTest(并行执行)。
- 运行:
项目包含完整的单元测试,位于解决方案同级的 `test` 目录中。
```bash
dotnet test
# 运行所有测试
dotnet test Sharp.sln
```
核心用例位于 `test/Common.Test`,覆盖 Result 与 Global 的主要功能与边界场景。
## 🤝 贡献指南
欢迎提交 Pull Request 或 Issue。请确保:
1. 代码风格符合 .NET 规范。
2. 新增功能包含对应的单元测试。
3. 提交信息简洁明了。
## WPF 工具包(Sharp.Wpf)
提供即插即用的 WPF 常用基础能力:
- 全局异常处理:统一捕获 UI/后台/未观察任务异常,支持弹窗与自定义日志
- MVVM 基础:`ViewModelBase`(INPC/IsBusy)、`RelayCommand`、`AsyncCommand`
- Dispatcher 封装:`IDispatcher`/`DispatcherService`,提供 UI 线程调用统一入口
- 对话与文件对话:`IDialogService`、`IFileDialogService`
- 常用转换器:Bool/NullOrEmpty 可见性、布尔取反、枚举描述
- 附加行为:EventToCommand(事件转 ICommand)
- 窗口工具:`WindowPlacement` 窗口位置/大小/状态持久化
### 引用
在你的 WPF 项目中添加项目引用:
```xml
<!-- Client/Client.csproj -->
<ItemGroup>
<ProjectReference Include="..\Wpf\Wpf.csproj" />
<!-- 或通过 NuGet: <PackageReference Include="Sharp.Wpf" Version="x.y.z" /> -->
</ItemGroup>
```
### 全局异常处理(App 启动)
```csharp
// App.Startup.cs
using System.Windows;
using Sharp.Wpf.Exceptions;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
new UiExceptionHandler().Register(new ExceptionHandlingOptions
{
ShowDialog = true,
DialogTitle = "应用程序错误"
});
}
}
```
### XAML 集成(窗口位置、行为、转换器)
```xml
<Window x:Class="Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="clr-namespace:Sharp.Wpf.Behaviors;assembly=Sharp.Wpf"
xmlns:w="clr-namespace:Sharp.Wpf.Windowing;assembly=Sharp.Wpf"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
Title="MainWindow"
w:WindowPlacement.RememberPlacement="True"
w:WindowPlacement.PlacementId="MainWindow">
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</Window.Resources>
<StackPanel Margin="16">
<Button Content="点击弹窗"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ThrowCommand}" />
<TextBlock Text="正在处理..."
Visibility="{Binding IsBusy, Converter={StaticResource Bool2Vis}}"/>
</StackPanel>
</Window>
```
### ViewModel 与命令
```csharp
using System;
using System.Threading.Tasks;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialog = new DialogService();
public string Greeting { get; set; } = "欢迎使用 Sharp.Wpf";
public IAsyncCommand ShowDialogCommand { get; }
public RelayCommand ThrowCommand { get; }
public MainViewModel()
{
ShowDialogCommand = new AsyncCommand(async () =>
{
try { IsBusy = true; await Task.Delay(300); await _dialog.ShowInfoAsync("Hello!"); }
finally { IsBusy = false; }
});
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示未处理异常"));
}
}
```
### 文件对话
```csharp
var files = await new FileDialogService().OpenFilesAsync("Images|*.png;*.jpg");
var folder = await new FileDialogService().SelectFolderAsync();
```
## 路线图(Roadmap)
- P0(最优先)
- Logging 抽象与 Serilog 适配;EventBus 进程内 Typed 事件;Config 对接 IOptions 与变更广播
- P1
- Net.Client:HttpClientFactory 封装 + Result<T> 模式;WPF Toolkit:MVVM/事件聚合/通知与主题
- P2
- 可靠性(Polly)、OpenTelemetry、外部 Broker 适配(Redis/RabbitMQ/Kafka)、敏感字段加密
- P3
- 文档/示例完善、CI/CD(自动打包发布)、更多单测与性能基准
## 贡献与规范
- 欢迎通过 Issue/PR 参与贡献。
- 代码风格:.NET 8、可空启用、简化 `using`,命名空间建议统一为 `Sharp.*`(Common 模块后续将统一为 `Sharp.Common`)。
- 变更需保持向后兼容(次版本),破坏式变更请在主版本发布并注明迁移指南。
## 许可
本项目采用 GNU AGPLv3 许可证,详见根目录 `LICENSE`。
如需在私有环境或商业场景中使用且不希望开源网络服务端改动代码,请联系作者洽谈其他授权方式。
## 📄 许可证
MIT License
+361
View File
@@ -0,0 +1,361 @@
{
// ========================================
// Sharp-Basic 框架配置模板
//
// 这是一个完整的配置文件模板,包含了所有Sharp模块的配置示例
// 使用时可以根据项目需要选择性保留相关配置节
// ========================================
// ========================================
// 系统配置 (System)
// 约定配置节名称:System
// 用于:NetClient、NetServer 的默认HTTP配置
// ========================================
"System": {
// HTTP服务监听IP地址
// 支持:IP地址、localhost、域名
"HTTPIp": "127.0.0.1",
// HTTP服务监听端口
// 范围:1-65535
"HttpPort": 8080,
// HTTP请求超时时间(秒)
// 范围:1-600
"TimeoutSeconds": 30,
// HTTP协议
// 可选值:http、https
"Scheme": "http"
},
// ========================================
// 数据库配置 (Database)
// 约定配置节名称:Database
// 用于:Data 模块 - AddSharpDatabase<TContext>(configuration)
// ========================================
"Database": {
// 数据库提供程序
// 可选值:SqlServer、Sqlite、PostgreSQL、MySql、InMemory
"Provider": "SqlServer",
// 数据库连接字符串
// SqlServer示例:Server=.;Database=MyApp;Trusted_Connection=True;TrustServerCertificate=True;
// Sqlite示例:Data Source=app.db
// PostgreSQL示例:Host=localhost;Database=myapp;Username=postgres;Password=123456
// MySql示例:server=localhost;database=myapp;user=root;password=123456
"ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;TrustServerCertificate=True;"
},
// ========================================
// 日志配置 (Logging)
// 约定配置节名称:Logging
// 用于:Log 模块 - AddLogging(b => b.AddSharpLog())
// ========================================
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
// Sharp日志扩展配置
"SharpLog": {
// 日志文件路径(相对或绝对路径)
"LogFilePath": "logs/app-.log",
// 日志滚动间隔
// 可选值:Infinite、Year、Month、Day、Hour、Minute
"RollingInterval": "Day",
// 单个日志文件最大大小(字节)
// null 表示不限制
"FileSizeLimitBytes": 10485760,
// 保留的日志文件数量
// null 表示不限制
"RetainedFileCountLimit": 31,
// 是否输出到控制台
"EnableConsole": true,
// 是否输出到文件
"EnableFile": true,
// 是否启用结构化日志(JSON格式)
"EnableStructuredLog": false
},
// ========================================
// HTTP客户端配置 (NetClient)
// 约定配置节名称:NetClient
// 用于:NetClient 模块 - AddNetClient(configuration.GetSection("NetClient"))
// ========================================
"NetClient": {
// 基础地址(留空则使用System配置)
"BaseAddress": "http://localhost:8080",
// 请求超时时间
"Timeout": "00:00:30",
// 是否启用日志
"EnableLogging": true,
// 是否包含关联ID
"IncludeCorrelationId": true,
// 关联ID请求头名称
"CorrelationIdHeaderName": "X-Correlation-ID",
// 是否使用Bearer Token认证
"UseBearerToken": false,
// 重试配置
"Retry": {
// 是否启用重试
"Enabled": true,
// 最大重试次数
"MaxRetries": 3,
// 重试延迟(秒)
"DelaySeconds": 2
},
// 默认请求头
"DefaultRequestHeaders": {
"User-Agent": "Sharp-NetClient/1.0",
"Accept": "application/json"
}
},
// ========================================
// HTTP服务端配置 (NetServer)
// 约定配置节名称:NetServer
// 用于:NetServer 模块 - AddNetServer(configManager)
// ========================================
"NetServer": {
// 监听URL列表(留空则使用System配置)
"ListenUrls": [
"http://localhost:8080/",
"http://0.0.0.0:8080/"
],
// 是否启用详细错误信息
"ShowDetailedErrors": true,
// 请求正文最大大小(字节)
"MaxRequestBodySize": 30000000,
// 请求超时时间
"RequestTimeout": "00:05:00"
},
// ========================================
// PLC配置 - 西门子 (Plc:Siemens)
// 约定配置节名称:Plc:Siemens
// 用于:Plc.Siemens 模块 - AddSiemensPlc(configuration.GetSection("Plc:Siemens"))
// ========================================
"Plc": {
"Siemens": {
// PLC IP地址
"IpAddress": "192.168.1.100",
// CPU类型
// 可选值:S71200、S71500、S7300、S7400、S7200、S7200Smart
"CpuType": "S71200",
// 机架号(S7-300/400)
"Rack": 0,
// 插槽号(S7-300/400)
"Slot": 1,
// 端口号
"Port": 102,
// 连接超时时间(毫秒)
"ConnectTimeout": 5000,
// 读写超时时间(毫秒)
"ReadWriteTimeout": 3000,
// 监控轮询间隔(毫秒)
"MonitorInterval": 500,
// 是否自动重连
"AutoReconnect": true,
// 重连间隔(毫秒)
"ReconnectInterval": 5000
}
},
// ========================================
// 串口配置 (SerialPort)
// 约定配置节名称:SerialPort
// 用于:Ports 模块 - AddSerialPortService()
// ========================================
"SerialPort": {
// 端口名称(如 COM1、/dev/ttyUSB0)
"PortName": "COM1",
// 波特率
// 常用值:9600、19200、38400、57600、115200
"BaudRate": 9600,
// 数据位
// 可选值:5、6、7、8
"DataBits": 8,
// 停止位
// 可选值:None、One、Two、OnePointFive
"StopBits": "One",
// 校验位
// 可选值:None、Odd、Even、Mark、Space
"Parity": "None",
// 读取超时时间(毫秒)
// -1 表示无限等待
"ReadTimeout": 1000,
// 写入超时时间(毫秒)
// -1 表示无限等待
"WriteTimeout": 1000,
// 接收缓冲区大小(字节)
"ReadBufferSize": 4096,
// 发送缓冲区大小(字节)
"WriteBufferSize": 2048
},
// ========================================
// 进程执行配置 (Process)
// 约定配置节名称:Process
// 用于:Process 模块 - AddSharpProcess()
// ========================================
"Process": {
// 默认工作目录
"WorkingDirectory": "",
// 默认超时时间(毫秒)
// -1 表示无限等待
"DefaultTimeout": 30000,
// 是否重定向标准输出
"RedirectStandardOutput": true,
// 是否重定向标准错误
"RedirectStandardError": true,
// 是否使用Shell执行
"UseShellExecute": false,
// 是否创建窗口
"CreateNoWindow": true
},
// ========================================
// Python配置 (Python)
// 约定配置节名称:Python
// 用于:Python 模块 - AddSharpPython()
// ========================================
"Python": {
// Python解释器路径
// 留空则自动查找系统PATH中的python
"PythonPath": "",
// Python脚本根目录
"ScriptDirectory": "scripts",
// 默认超时时间(毫秒)
"DefaultTimeout": 60000,
// 是否启用虚拟环境
"UseVirtualEnv": false,
// 虚拟环境路径
"VirtualEnvPath": ""
},
// ========================================
// 缓存配置 (Cache)
// 约定配置节名称:Cache
// 用于:Cache 模块 - AddSharpCache()
// ========================================
"Cache": {
// 默认过期时间(秒)
"DefaultExpirationSeconds": 600,
// 缓存大小限制(条目数)
// null 表示不限制
"SizeLimit": null,
// 内存压缩比例(0.0-1.0)
"CompactionPercentage": 0.25,
// 过期扫描频率(秒)
"ExpirationScanFrequency": 60
},
// ========================================
// 事件总线配置 (EventBus)
// 约定配置节名称:EventBus
// 用于:EventBus 模块 - AddEventBus()
// ========================================
"EventBus": {
// 是否启用异步处理
"EnableAsync": true,
// 异步事件队列容量
"QueueCapacity": 1000,
// 是否在异常时继续执行其他订阅者
"ContinueOnError": true,
// 是否启用死信队列
"EnableDeadLetterQueue": false,
// 死信队列容量
"DeadLetterQueueCapacity": 100
},
// ========================================
// WPF配置 (Wpf)
// 约定配置节名称:Wpf
// 用于:Wpf 模块 - AddSharpWpf()
// ========================================
"Wpf": {
// 默认对话框标题
"DefaultDialogTitle": "提示",
// 是否启用全局异常处理
"EnableGlobalExceptionHandler": true,
// 主题
// 可选值:Light、Dark
"Theme": "Light",
// 语言
// 可选值:zh-CN、en-US
"Language": "zh-CN"
},
// ========================================
// 环境变量覆盖说明
// ========================================
// 所有配置都可以通过环境变量覆盖,格式:
// SHARP__{节名}__{键名}
//
// 示例:
// SHARP__Database__Provider=Sqlite
// SHARP__Database__ConnectionString=Data Source=test.db
// SHARP__Plc__Siemens__IpAddress=192.168.1.200
// SHARP__System__HttpPort=9090
//
// 注意:双下划线 __ 用于分隔层级
// ========================================
}
+194
View File
@@ -0,0 +1,194 @@
# 01 - 最小 WPF 应用
这是一个最简单的 Sharp WPF 应用示例,演示了框架的基础用法。
## 🎯 学习目标
- 了解 Sharp 框架的基本结构
- 掌握 MVVM 模式(ViewModelBase)
- 使用 Commands(RelayCommand, AsyncCommand)
- 使用对话框服务(IDialogService)
- 理解 Quick Start 配置方式
## 📦 使用的模块
- **Sharp.Common** - Result<T>, 基础工具
- **Sharp.Config** - 配置管理
- **Sharp.Wpf** - MVVM 基础设施
## 🏗️ 项目结构
```
01-MinimalWpfApp/
├── App.xaml # 应用程序入口
├── App.xaml.cs # Quick Start 配置
├── MainWindow.xaml # 主窗口界面
├── MainWindow.xaml.cs # 主窗口代码隐藏
├── ViewModels/
│ └── MainViewModel.cs # 主窗口 ViewModel
├── appsettings.json # 配置文件
└── README.md # 本文件
```
## 🚀 快速运行
```bash
cd examples/01-MinimalWpfApp
dotnet run
```
## 📝 核心代码讲解
### 1. App.xaml.cs - 应用启动
```csharp
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
// 一行添加基础服务(配置、日志、缓存)
services.AddSharpEssentials();
// 一行添加 WPF 服务(对话框、调度器、导航、主题)
services.AddSharpWpf();
// 注册 ViewModel 和 Window
services.AddViewModel<MainViewModel>();
services.AddTransient<MainWindow>();
// 构建服务提供者
_serviceProvider = services.BuildServiceProvider();
// 显示主窗口
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
**关键点**:
- `AddSharpEssentials()` - 自动配置 Config + Log + Cache
- `AddSharpWpf()` - 自动注册 WPF 服务
- 无需手动配置,使用约定优于配置
### 2. MainViewModel.cs - ViewModel 示例
```csharp
public class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private string _message = "Hello, Sharp!";
private int _counter = 0;
public string Message
{
get => _message;
set => SetProperty(ref _message, value);
}
public int Counter
{
get => _counter;
set => SetProperty(ref _counter, value);
}
// 同步命令
public ICommand IncrementCommand { get; }
// 异步命令
public ICommand ShowDialogCommand { get; }
public MainViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
// RelayCommand - 同步操作
IncrementCommand = new RelayCommand(() => {
Counter++;
Message = $"你点击了 {Counter} 次";
});
// AsyncCommand - 异步操作
ShowDialogCommand = new AsyncCommand(async (ct) => {
IsBusy = true; // 显示加载状态
// 模拟耗时操作
await Task.Delay(1000, ct);
_dialogService.ShowMessage($"当前计数: {Counter}", "提示");
IsBusy = false;
});
}
}
```
**关键点**:
- 继承 `ViewModelBase` 获得 INotifyPropertyChanged
- 使用 `SetProperty()` 自动触发属性变化通知
- `IsBusy` 属性用于显示加载状态
- `RelayCommand` 用于同步操作
- `AsyncCommand` 用于异步操作(支持取消)
### 3. MainWindow.xaml - 界面绑定
```xml
<Window ...>
<Window.DataContext>
<!-- ViewModel 会通过 DI 自动注入 -->
</Window.DataContext>
<StackPanel Margin="20">
<!-- 绑定属性 -->
<TextBlock Text="{Binding Message}" FontSize="24"/>
<!-- 绑定命令 -->
<Button Content="点击我" Command="{Binding IncrementCommand}"/>
<!-- 异步命令 -->
<Button Content="显示对话框" Command="{Binding ShowDialogCommand}"/>
<!-- 显示加载状态 -->
<TextBlock Text="加载中..." Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StackPanel>
</Window>
```
**关键点**:
- 使用 `{Binding}` 绑定 ViewModel 属性
- Command 绑定到 ICommand 属性
- 使用内置转换器(BoolToVisibilityConverter)
## 🎓 练习建议
### 练习1:添加新属性
在 MainViewModel 中添加一个 `Name` 属性,并在界面上显示。
### 练习2:添加清零按钮
添加一个 `ResetCommand`,点击后将 `Counter` 重置为 0。
### 练习3:使用对话框确认
在 ResetCommand 中,先弹出确认对话框,用户确认后再清零。
### 练习4:尝试 Result<T>
修改 ShowDialogCommand,使用 `Result.Try()` 包装可能出错的代码。
## 📚 延伸阅读
- [Sharp.Wpf README](../../src/Sharp/Wpf/README.md) - WPF 模块完整文档
- [QUICKSTART.md](../../src/Sharp/QUICKSTART.md) - 快速启动指南
- 下一个示例:[02-WpfWithDatabase](../02-WpfWithDatabase/README.md)
## ❓ 常见问题
**Q: 为什么 MainWindow 不需要手动设置 DataContext?**
A: 通过 DI 容器获取 Window 时,会自动注入对应的 ViewModel。
**Q: IsBusy 是在哪里定义的?**
A: `IsBusy` 是 `ViewModelBase` 的内置属性,用于显示加载状态。
**Q: 可以不用 DI 吗?**
A: 可以,但不推荐。使用 DI 可以更好地管理依赖关系,便于测试。
---
**开始编码吧!** 🚀
+233
View File
@@ -0,0 +1,233 @@
# 03 - 表单验证示例
演示如何使用 Sharp.Wpf 的表单验证功能,包括 DataAnnotations 验证、实时错误提示和自定义验证。
## 🎯 学习目标
- 使用 `ValidatableViewModelBase` 实现表单验证
- 掌握 DataAnnotations 验证特性
- 实现实时验证反馈
- 自定义验证规则
- 显示验证错误消息
## 📦 使用的模块
- **Sharp.Wpf** - ValidatableViewModelBase, 验证支持
## 🚀 快速运行
```bash
cd examples/03-FormValidation
dotnet run
```
## 📝 核心代码讲解
### 1. UserViewModel.cs - 带验证的 ViewModel
```csharp
using System.ComponentModel.DataAnnotations;
using Wpf.Validation;
public class UserViewModel : ValidatableViewModelBase
{
private string _username = "";
private string _email = "";
private string _password = "";
private string _confirmPassword = "";
private int _age;
[Required(ErrorMessage = "用户名不能为空")]
[StringLength(20, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-20个字符之间")]
public string Username
{
get => _username;
set => SetProperty(ref _username, value, validate: true); // 启用验证
}
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email
{
get => _email;
set => SetProperty(ref _email, value, validate: true);
}
[Required(ErrorMessage = "密码不能为空")]
[StringLength(50, MinimumLength = 6, ErrorMessage = "密码长度必须在6-50个字符之间")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$",
ErrorMessage = "密码必须包含大小写字母和数字")]
public string Password
{
get => _password;
set
{
SetProperty(ref _password, value, validate: true);
// 密码变化时,重新验证确认密码
ValidateProperty(ConfirmPassword, nameof(ConfirmPassword));
}
}
[Required(ErrorMessage = "请确认密码")]
public string ConfirmPassword
{
get => _confirmPassword;
set
{
SetProperty(ref _confirmPassword, value, validate: false);
// 自定义验证:确认密码必须与密码一致
if (value != Password)
{
AddError(nameof(ConfirmPassword), "两次密码输入不一致");
}
else
{
ClearErrors(nameof(ConfirmPassword));
}
}
}
[Range(18, 120, ErrorMessage = "年龄必须在18-120之间")]
public int Age
{
get => _age;
set => SetProperty(ref _age, value, validate: true);
}
// 提交命令
public ICommand SubmitCommand { get; }
public UserViewModel(IDialogService dialogService)
{
SubmitCommand = new RelayCommand(() =>
{
// 验证所有属性
if (ValidateAll())
{
dialogService.ShowMessage(
$"注册成功!\n用户名: {Username}\n邮箱: {Email}\n年龄: {Age}",
"成功");
}
else
{
dialogService.ShowMessage("请修正表单错误后再提交", "验证失败");
}
});
}
}
```
### 2. MainWindow.xaml - 验证错误显示
```xml
<Window x:Class="FormValidationExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="表单验证示例" Height="500" Width="400">
<Window.Resources>
<!-- 错误模板 -->
<ControlTemplate x:Key="ValidationErrorTemplate">
<DockPanel>
<Border BorderBrush="Red" BorderThickness="1" CornerRadius="2">
<AdornedElementPlaceholder/>
</Border>
<TextBlock Foreground="Red" FontSize="12" Margin="5,0,0,0"
Text="{Binding [0].ErrorContent}"
DockPanel.Dock="Right"/>
</DockPanel>
</ControlTemplate>
</Window.Resources>
<StackPanel Margin="20">
<TextBlock Text="用户注册表单" FontSize="24" FontWeight="Bold" Margin="0,0,0,20"/>
<!-- 用户名 -->
<TextBlock Text="用户名:"/>
<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,15"/>
<!-- 邮箱 -->
<TextBlock Text="邮箱:"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,15"/>
<!-- 密码 -->
<TextBlock Text="密码:"/>
<PasswordBox x:Name="PasswordBox" Margin="0,5,0,5"/>
<TextBlock Text="* 密码必须包含大小写字母和数字" FontSize="10" Foreground="Gray" Margin="0,0,0,15"/>
<!-- 确认密码 -->
<TextBlock Text="确认密码:"/>
<PasswordBox x:Name="ConfirmPasswordBox" Margin="0,5,0,15"/>
<!-- 年龄 -->
<TextBlock Text="年龄:"/>
<TextBox Text="{Binding Age, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"
Margin="0,5,0,20"/>
<!-- 提交按钮 -->
<Button Content="提交注册" Command="{Binding SubmitCommand}"
Height="40" FontSize="16"/>
<!-- 验证状态 -->
<TextBlock Margin="0,10,0,0" Foreground="Red"
Text="表单包含错误,请修正后提交"
Visibility="{Binding HasErrors, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StackPanel>
</Window>
```
### 3. 关键点说明
**ValidatableViewModelBase 的关键特性**:
1. **实时验证** - `SetProperty(ref field, value, validate: true)`
2. **验证所有属性** - `ValidateAll()` 返回 bool
3. **自定义错误** - `AddError(propertyName, errorMessage)`
4. **清除错误** - `ClearErrors(propertyName)`
5. **错误状态** - `HasErrors` 属性
**XAML 绑定要点**:
1. **UpdateSourceTrigger=PropertyChanged** - 实时触发验证
2. **ValidatesOnNotifyDataErrors=True** - 启用错误显示
3. **Validation.ErrorTemplate** - 自定义错误样式
## 🎓 练习建议
### 练习1:添加更多验证规则
添加电话号码字段,使用正则表达式验证手机号格式。
### 练习2:异步验证
实现用户名唯一性检查(模拟调用 API)。
### 练习3:条件验证
当用户勾选"我是学生"时,年龄必须小于25岁。
### 练习4:自定义验证特性
创建 `UniqueUsernameAttribute` 自定义验证特性。
## 💡 常见问题
**Q: PasswordBox 无法绑定,怎么办?**
A: PasswordBox 的 Password 属性不是依赖属性,不能直接绑定。可以使用附加属性或在代码隐藏中处理。
**Q: 为什么有时验证不触发?**
A: 确保:
1. 绑定使用了 `UpdateSourceTrigger=PropertyChanged`
2. SetProperty 的 validate 参数设为 true
3. XAML 中设置了 `ValidatesOnNotifyDataErrors=True`
**Q: 如何自定义错误消息样式?**
A: 修改 `Validation.ErrorTemplate`,可以自定义边框颜色、图标等。
## 📚 延伸阅读
- [DataAnnotations 验证特性](https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.dataannotations)
- [INotifyDataErrorInfo 接口](https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.inotifydataerrorinfo)
- 下一个示例:[04-Navigation](../04-Navigation/README.md)
---
**掌握表单验证,构建高质量的用户输入体验!** ✅
+315
View File
@@ -0,0 +1,315 @@
# 04 - 导航系统示例
演示如何使用 Sharp.Wpf 的导航服务实现多页面 WPF 应用,包括页面跳转、参数传递、前进/后退等功能。
## 🎯 学习目标
- 使用 `INavigationService` 实现页面导航
- 理解导航历史栈(前进/后退)
- 页面间参数传递
- 导航生命周期钩子(INavigationAware)
- 弹出式导航并获取结果
## 📦 使用的模块
- **Sharp.Wpf** - INavigationService, NavigationService
## 🚀 快速运行
```bash
cd examples/04-Navigation
dotnet run
```
## 📝 核心代码讲解
### 1. MainViewModel.cs - 主导航容器
```csharp
using Wpf.Navigation;
public class MainViewModel : ViewModelBase
{
private readonly INavigationService _navigationService;
private ViewModelBase? _currentPage;
public ViewModelBase? CurrentPage
{
get => _currentPage;
set => SetProperty(ref _currentPage, value);
}
public bool CanGoBack => _navigationService.CanGoBack;
public bool CanGoForward => _navigationService.CanGoForward;
public ICommand NavigateToHomeCommand { get; }
public ICommand NavigateToProductsCommand { get; }
public ICommand NavigateToSettingsCommand { get; }
public ICommand GoBackCommand { get; }
public ICommand GoForwardCommand { get; }
public MainViewModel(INavigationService navigationService)
{
_navigationService = navigationService;
// 监听导航事件
_navigationService.Navigated += (s, vm) =>
{
CurrentPage = vm;
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoForward));
};
// 导航命令
NavigateToHomeCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<HomeViewModel>();
});
NavigateToProductsCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<ProductsViewModel>();
});
NavigateToSettingsCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.NavigateAsync<SettingsViewModel>();
});
GoBackCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.GoBackAsync();
}, () => CanGoBack);
GoForwardCommand = new AsyncCommand(async (ct) =>
{
await _navigationService.GoForwardAsync();
}, () => CanGoForward);
// 初始导航到首页
Task.Run(async () => await _navigationService.NavigateAsync<HomeViewModel>());
}
}
```
### 2. ProductsViewModel.cs - 带参数的页面
```csharp
public class ProductsViewModel : ViewModelBase, INavigationAware
{
private readonly INavigationService _navigationService;
private readonly IDialogService _dialogService;
private string _category = "全部";
public string Category
{
get => _category;
set => SetProperty(ref _category, value);
}
public ICommand ViewProductDetailsCommand { get; }
public ProductsViewModel(
INavigationService navigationService,
IDialogService dialogService)
{
_navigationService = navigationService;
_dialogService = dialogService;
ViewProductDetailsCommand = new AsyncCommand<int>(async (productId, ct) =>
{
// 导航到产品详情页,传递产品ID
await _navigationService.NavigateAsync<ProductDetailViewModel>(productId);
});
}
// 导航生命周期:进入页面时
public async Task OnNavigatedToAsync(object? parameter)
{
if (parameter is string category)
{
Category = category;
_dialogService.ShowMessage($"显示分类: {category}", "提示");
}
// 可以在这里加载数据
await LoadProductsAsync();
}
// 导航生命周期:离开页面时
public async Task OnNavigatedFromAsync()
{
// 清理资源、保存状态等
await Task.CompletedTask;
}
private async Task LoadProductsAsync()
{
IsBusy = true;
await Task.Delay(500); // 模拟加载
IsBusy = false;
}
}
```
### 3. ProductDetailViewModel.cs - 详情页(带结果返回)
```csharp
public class ProductDetailViewModel : ViewModelBase,
INavigationAware,
INavigationResult<bool> // 返回 bool(是否购买)
{
private readonly TaskCompletionSource<bool> _resultSource = new();
private int _productId;
private string _productName = "";
public int ProductId
{
get => _productId;
set => SetProperty(ref _productId, value);
}
public string ProductName
{
get => _productName;
set => SetProperty(ref _productName, value);
}
public ICommand BuyCommand { get; }
public ICommand CancelCommand { get; }
public ProductDetailViewModel()
{
BuyCommand = new RelayCommand(() =>
{
// 返回结果:已购买
_resultSource.SetResult(true);
});
CancelCommand = new RelayCommand(() =>
{
// 返回结果:取消
_resultSource.SetResult(false);
});
}
public async Task OnNavigatedToAsync(object? parameter)
{
if (parameter is int productId)
{
ProductId = productId;
// 加载产品详情
ProductName = $"产品 #{productId}";
}
await Task.CompletedTask;
}
public Task OnNavigatedFromAsync() => Task.CompletedTask;
// 实现 INavigationResult<bool>
public Task<bool> GetResultAsync()
{
return _resultSource.Task;
}
}
```
### 4. App.xaml.cs - 注册导航服务
```csharp
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf(); // 自动注册 INavigationService
// 注册所有页面 ViewModel
services.AddViewModel<MainViewModel>();
services.AddViewModel<HomeViewModel>();
services.AddViewModel<ProductsViewModel>();
services.AddViewModel<ProductDetailViewModel>();
services.AddViewModel<SettingsViewModel>();
var provider = services.BuildServiceProvider();
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 5. MainWindow.xaml - 导航容器
```xml
<Window x:Class="NavigationExample.MainWindow"
Title="导航系统示例" Height="600" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 导航栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Background="#f0f0f0" Padding="10">
<Button Content="首页" Command="{Binding NavigateToHomeCommand}" Margin="0,0,10,0"/>
<Button Content="产品列表" Command="{Binding NavigateToProductsCommand}" Margin="0,0,10,0"/>
<Button Content="设置" Command="{Binding NavigateToSettingsCommand}" Margin="0,0,20,0"/>
<Button Content="← 后退" Command="{Binding GoBackCommand}" Margin="0,0,10,0"/>
<Button Content="前进 →" Command="{Binding GoForwardCommand}"/>
</StackPanel>
<!-- 内容区域 - 显示当前页面 -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}">
<ContentControl.Resources>
<!-- 为每个 ViewModel 指定对应的 View -->
<DataTemplate DataType="{x:Type local:HomeViewModel}">
<local:HomeView/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:ProductsViewModel}">
<local:ProductsView/>
</DataTemplate>
<DataTemplate DataType="{x:Type local:SettingsViewModel}">
<local:SettingsView/>
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</Grid>
</Window>
```
## 🎓 练习建议
### 练习1:面包屑导航
添加面包屑显示当前导航路径(如:首页 > 产品列表 > 产品详情)。
### 练习2:导航守卫
实现 `INavigationGuard` 接口,在离开页面前检查是否有未保存的更改。
### 练习3:嵌套导航
在产品列表页内部实现子导航(如:全部/热销/新品标签页切换)。
### 练习4:导航缓存
实现页面缓存,避免重复创建 ViewModel(保持页面状态)。
## 💡 常见问题
**Q: 导航后如何保持页面状态?**
A: 不要后退时,CurrentViewModel 会保留在前进栈中,前进时会恢复状态。如果需要完全保持状态,可以使用 Singleton ViewModel。
**Q: 如何传递复杂对象作为参数?**
A: 使用 `NavigateAsync(object parameter)`,parameter 可以是任何对象。
**Q: 导航历史栈何时清空?**
A: 调用 `_navigationService.ClearHistory()` 手动清空。
**Q: 能否使用动画过渡?**
A: 可以,在 ContentControl 上添加 Storyboard 实现页面切换动画。
## 📚 延伸阅读
- [WPF 数据模板](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/data/data-templating-overview)
- [MVVM 导航模式](https://prismlibrary.com/docs/navigation.html)
- 下一个示例:[05-Localization](../05-Localization/README.md)
---
**掌握导航系统,构建流畅的多页面应用!** 🧭
+278
View File
@@ -0,0 +1,278 @@
# 05 - 国际化(i18n)示例
演示如何使用 Sharp.Wpf 的国际化功能实现多语言支持,包括运行时切换语言、资源文件管理等。
## 🎯 学习目标
- 使用 `ILocalizationService` 实现多语言
- 在 XAML 中使用 `{loc:Localize}` 绑定本地化字符串
- 运行时动态切换语言
- 管理资源文件(.resx)
- 格式化带参数的本地化字符串
## 📦 使用的模块
- **Sharp.Wpf** - ILocalizationService, LocalizeExtension
## 🚀 快速运行
```bash
cd examples/05-Localization
dotnet run
```
## 📝 核心代码讲解
### 1. 创建资源文件
```
Resources/
├── Strings.resx # 默认语言(中文)
├── Strings.en-US.resx # 英文
└── Strings.ja-JP.resx # 日文(可选)
```
**Strings.resx** (中文):
```xml
<data name="Welcome" xml:space="preserve">
<value>欢迎使用 Sharp 框架</value>
</data>
<data name="Username" xml:space="preserve">
<value>用户名</value>
</data>
<data name="Password" xml:space="preserve">
<value>密码</value>
</data>
<data name="Login" xml:space="preserve">
<value>登录</value>
</data>
<data name="WelcomeUser" xml:space="preserve">
<value>欢迎, {0}!</value>
</data>
```
**Strings.en-US.resx** (英文):
```xml
<data name="Welcome" xml:space="preserve">
<value>Welcome to Sharp Framework</value>
</data>
<data name="Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Login" xml:space="preserve">
<value>Login</value>
</data>
<data name="WelcomeUser" xml:space="preserve">
<value>Welcome, {0}!</value>
</data>
```
### 2. App.xaml.cs - 注册国际化服务
```csharp
using System.Globalization;
using System.Resources;
using Wpf.Localization;
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf();
// 添加国际化支持
var resourceManager = new ResourceManager(
"LocalizationExample.Resources.Strings", // 资源文件的完整命名空间
Assembly.GetExecutingAssembly());
services.AddLocalization(
resourceManager,
new CultureInfo("zh-CN"), // 中文
new CultureInfo("en-US"), // 英文
new CultureInfo("ja-JP") // 日文
);
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 3. MainViewModel.cs - 语言切换
```csharp
using Wpf.Localization;
public class MainViewModel : ViewModelBase
{
private readonly ILocalizationService _localization;
private readonly IDialogService _dialogService;
public ILocalizationService Localization => _localization;
public List<CultureInfo> AvailableLanguages { get; }
public CultureInfo CurrentLanguage
{
get => _localization.CurrentCulture;
set
{
_localization.ChangeCulture(value);
OnPropertyChanged();
}
}
public ICommand ChangeLanguageCommand { get; }
public ICommand ShowGreetingCommand { get; }
public MainViewModel(
ILocalizationService localization,
IDialogService dialogService)
{
_localization = localization;
_dialogService = dialogService;
AvailableLanguages = localization.GetSupportedCultures().ToList();
// 监听语言变化
_localization.CultureChanged += (s, culture) =>
{
_dialogService.ShowMessage(
$"语言已切换到: {culture.NativeName}",
"提示");
};
ChangeLanguageCommand = new RelayCommand<CultureInfo>(culture =>
{
if (culture != null)
{
CurrentLanguage = culture;
}
});
ShowGreetingCommand = new RelayCommand(() =>
{
// 使用格式化字符串
var message = _localization.GetString("WelcomeUser", "张三");
_dialogService.ShowMessage(message, "问候");
});
}
}
```
### 4. MainWindow.xaml - 使用本地化绑定
```xml
<Window x:Class="LocalizationExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:Wpf.Localization;assembly=Sharp.Wpf"
Title="国际化示例" Height="400" Width="500">
<StackPanel Margin="20">
<!-- 方式1: 使用 LocalizeExtension(推荐) -->
<TextBlock Text="{loc:Localize Welcome}" FontSize="24" FontWeight="Bold"/>
<!-- 语言选择 -->
<StackPanel Orientation="Horizontal" Margin="0,20,0,0">
<TextBlock Text="选择语言:" VerticalAlignment="Center" Margin="0,0,10,0"/>
<ComboBox ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding CurrentLanguage}"
DisplayMemberPath="NativeName"
Width="150"/>
</StackPanel>
<!-- 登录表单 -->
<StackPanel Margin="0,30,0,0">
<!-- 用户名 -->
<TextBlock Text="{loc:Localize Username}"/>
<TextBox Margin="0,5,0,15"/>
<!-- 密码 -->
<TextBlock Text="{loc:Localize Password}"/>
<PasswordBox Margin="0,5,0,15"/>
<!-- 登录按钮 -->
<Button Content="{loc:Localize Login}"
Height="35" FontSize="14"/>
</StackPanel>
<!-- 方式2: 直接绑定 Localization 服务 -->
<StackPanel Margin="0,30,0,0">
<TextBlock Text="{Binding Localization[Welcome]}" FontStyle="Italic"/>
<TextBlock Text="{Binding Localization[Username]}" Margin="0,5,0,0"/>
</StackPanel>
<!-- 测试格式化字符串 -->
<Button Content="显示问候" Command="{Binding ShowGreetingCommand}" Margin="0,20,0,0"/>
</StackPanel>
</Window>
```
### 5. 在代码中使用本地化
```csharp
// 方式1: 通过服务获取
var welcomeText = _localization.GetString("Welcome");
// 方式2: 带格式化参数
var greeting = _localization.GetString("WelcomeUser", username);
// 方式3: 索引器(用于绑定)
var password = _localization["Password"];
```
## 🎓 练习建议
### 练习1:添加更多语言
添加法语(fr-FR)或德语(de-DE)资源文件。
### 练习2:保存语言偏好
将用户选择的语言保存到配置文件,下次启动时自动加载。
### 练习3:动态加载资源
实现从外部 JSON 文件加载翻译资源(而非编译进程序)。
### 练习4:翻译进度显示
显示每种语言的翻译完成度(如:英文 95%,日文 60%)。
## 💡 常见问题
**Q: 资源文件修改后不生效?**
A: 确保:
1. 资源文件的"生成操作"设为"嵌入的资源"
2. 重新生成项目(Clean + Rebuild)
**Q: 如何支持从右到左(RTL)语言?**
A: 监听 CultureChanged 事件,设置 `FlowDirection`:
```csharp
_localization.CultureChanged += (s, culture) => {
if (culture.TextInfo.IsRightToLeft) {
Application.Current.MainWindow.FlowDirection = FlowDirection.RightToLeft;
}
};
```
**Q: 能否热重载资源文件?**
A: Sharp.Wpf 当前使用编译时嵌入资源,不支持运行时重载。可以使用外部 JSON 文件实现热重载。
**Q: 如何处理复数形式?**
A: 使用不同的资源键或使用占位符:
```
"{Count} items" → "{0} 个项目"
```
## 📚 延伸阅读
- [.NET 资源文件](https://learn.microsoft.com/zh-cn/dotnet/core/extensions/resources)
- [CultureInfo 类](https://learn.microsoft.com/zh-cn/dotnet/api/system.globalization.cultureinfo)
- [WPF 本地化](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/advanced/globalization-for-wpf)
- 下一个示例:[06-Theming](../06-Theming/README.md)
---
**构建面向全球用户的应用!** 🌍
+312
View File
@@ -0,0 +1,312 @@
# 06 - 主题系统示例
演示如何使用 Sharp.Wpf 的主题系统实现浅色/深色主题切换,包括自定义颜色、跟随系统主题等功能。
## 🎯 学习目标
- 使用 `IThemeService` 管理应用主题
- 实现浅色/深色主题切换
- 自定义主题颜色
- 跟随系统主题设置
- 动态主题切换(无需重启)
## 📦 使用的模块
- **Sharp.Wpf** - IThemeService, ThemeService
## 🚀 快速运行
```bash
cd examples/06-Theming
dotnet run
```
## 📝 核心代码讲解
### 1. App.xaml.cs - 注册主题服务
```csharp
using Wpf.Theming;
protected override void OnStartup(StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSharpWpf(); // 自动注册 IThemeService
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
// 应用初始主题
var themeService = provider.GetRequiredService<IThemeService>();
themeService.ApplyTheme(Theme.Light); // 或 Theme.System
var mainWindow = provider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
```
### 2. MainViewModel.cs - 主题切换逻辑
```csharp
using Wpf.Theming;
public class MainViewModel : ViewModelBase
{
private readonly IThemeService _themeService;
private Theme _currentTheme;
public Theme CurrentTheme
{
get => _currentTheme;
set
{
SetProperty(ref _currentTheme, value);
_themeService.ApplyTheme(value);
}
}
public ICommand SwitchToLightCommand { get; }
public ICommand SwitchToDarkCommand { get; }
public ICommand SwitchToSystemCommand { get; }
public MainViewModel(IThemeService themeService)
{
_themeService = themeService;
_currentTheme = themeService.CurrentTheme;
// 监听主题变化
_themeService.ThemeChanged += (s, theme) =>
{
OnPropertyChanged(nameof(CurrentTheme));
};
SwitchToLightCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.Light;
});
SwitchToDarkCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.Dark;
});
SwitchToSystemCommand = new RelayCommand(() =>
{
CurrentTheme = Theme.System;
});
}
}
```
### 3. MainWindow.xaml - 使用主题颜色
```xml
<Window x:Class="ThemingExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="主题系统示例" Height="500" Width="600"
Background="{DynamicResource BackgroundColor}"> <!-- 使用主题颜色 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 主题切换栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal"
Background="{DynamicResource PrimaryColor}" Padding="15">
<TextBlock Text="选择主题:"
Foreground="White" VerticalAlignment="Center" Margin="0,0,10,0"/>
<Button Content="☀️ 浅色" Command="{Binding SwitchToLightCommand}"
Margin="0,0,10,0" Padding="15,5"/>
<Button Content="🌙 深色" Command="{Binding SwitchToDarkCommand}"
Margin="0,0,10,0" Padding="15,5"/>
<Button Content="🖥️ 跟随系统" Command="{Binding SwitchToSystemCommand}"
Padding="15,5"/>
</StackPanel>
<!-- 内容区域 -->
<ScrollViewer Grid.Row="1" Padding="20">
<StackPanel>
<TextBlock Text="主题颜色预览" FontSize="24" FontWeight="Bold"
Foreground="{DynamicResource ForegroundColor}" Margin="0,0,0,20"/>
<!-- 基础颜色 -->
<GroupBox Header="基础颜色" Margin="0,0,0,15">
<StackPanel>
<Border Background="{DynamicResource PrimaryColor}"
Height="40" Margin="0,5">
<TextBlock Text="PrimaryColor" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource BackgroundColor}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Height="40" Margin="0,5">
<TextBlock Text="BackgroundColor"
Foreground="{DynamicResource ForegroundColor}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
</StackPanel>
</GroupBox>
<!-- 状态颜色 -->
<GroupBox Header="状态颜色" Margin="0,0,0,15">
<StackPanel>
<Border Background="{DynamicResource SuccessColor}"
Height="40" Margin="0,5">
<TextBlock Text="✓ Success" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource WarningColor}"
Height="40" Margin="0,5">
<TextBlock Text="⚠ Warning" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource ErrorColor}"
Height="40" Margin="0,5">
<TextBlock Text="✗ Error" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<Border Background="{DynamicResource InfoColor}"
Height="40" Margin="0,5">
<TextBlock Text="ℹ Info" Foreground="White"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
</StackPanel>
</GroupBox>
<!-- 控件预览 -->
<GroupBox Header="控件预览" Margin="0,0,0,15">
<StackPanel>
<!-- 按钮 -->
<Button Content="普通按钮" Margin="0,5" Padding="10,5"
Background="{DynamicResource ButtonBackgroundColor}"
Foreground="{DynamicResource ForegroundColor}"/>
<!-- 输入框 -->
<TextBox Text="输入框示例" Margin="0,5" Padding="5"
Background="{DynamicResource InputBackgroundColor}"
BorderBrush="{DynamicResource InputBorderColor}"/>
<!-- 复选框 -->
<CheckBox Content="复选框示例" Margin="0,5"
Foreground="{DynamicResource ForegroundColor}"/>
<!-- 单选按钮 -->
<RadioButton Content="单选按钮示例" Margin="0,5"
Foreground="{DynamicResource ForegroundColor}"/>
</StackPanel>
</GroupBox>
<!-- 文本示例 -->
<GroupBox Header="文本示例">
<StackPanel>
<TextBlock Text="这是标题文本" FontSize="20" FontWeight="Bold"
Foreground="{DynamicResource ForegroundColor}" Margin="0,5"/>
<TextBlock Text="这是正文文本。主题切换后,所有使用 DynamicResource 的颜色都会自动更新,无需重启应用。"
TextWrapping="Wrap" Foreground="{DynamicResource ForegroundColor}"
Margin="0,5"/>
<TextBlock Text="这是次要文本" FontSize="12" FontStyle="Italic"
Foreground="Gray" Margin="0,5"/>
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid>
</Window>
```
### 4. 自定义主题颜色
```csharp
public class CustomThemeViewModel : ViewModelBase
{
private readonly IThemeService _themeService;
public ICommand ChangePrimaryColorCommand { get; }
public CustomThemeViewModel(IThemeService themeService)
{
_themeService = themeService;
ChangePrimaryColorCommand = new RelayCommand(() =>
{
// 打开颜色选择器(可以使用第三方库或自定义)
var color = Colors.DeepSkyBlue; // 示例:选择的颜色
// 更新主题颜色
_themeService.SetColor("PrimaryColor", color);
});
}
}
```
### 5. 主题资源字典(可选 - 高级用法)
可以创建独立的资源字典文件:
**Themes/LightTheme.xaml**:
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PrimaryColor" Color="#0078D7"/>
<SolidColorBrush x:Key="BackgroundColor" Color="White"/>
<SolidColorBrush x:Key="ForegroundColor" Color="Black"/>
<!-- ... 其他颜色 -->
</ResourceDictionary>
```
**Themes/DarkTheme.xaml**:
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PrimaryColor" Color="#0078D7"/>
<SolidColorBrush x:Key="BackgroundColor" Color="#202020"/>
<SolidColorBrush x:Key="ForegroundColor" Color="White"/>
<!-- ... 其他颜色 -->
</ResourceDictionary>
```
## 🎓 练习建议
### 练习1:添加更多预设主题
添加"高对比度"主题,适合视力不佳的用户。
### 练习2:主题动画过渡
在切换主题时添加颜色渐变动画,而非瞬间切换。
### 练习3:保存主题偏好
将用户选择的主题保存到配置文件,下次启动时自动应用。
### 练习4:自定义主题编辑器
创建一个界面,允许用户自定义每种颜色,并导出/导入主题配置。
## 💡 常见问题
**Q: StaticResource 和 DynamicResource 的区别?**
A:
- `StaticResource`: 编译时绑定,主题切换后不会更新
- `DynamicResource`: 运行时绑定,主题切换后自动更新(推荐用于主题)
**Q: 主题切换后部分控件没变化?**
A: 确保使用了 `{DynamicResource}` 而非 `{StaticResource}`。
**Q: 如何跟随 Windows 系统主题?**
A: 使用 `Theme.System`,Sharp.Wpf 会自动检测系统主题设置。
**Q: 能否为不同的窗口设置不同主题?**
A: 可以,但不推荐。可以为每个 Window 设置独立的 ResourceDictionary。
## 📚 延伸阅读
- [WPF 资源](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/systems/xaml-resources-overview)
- [WPF 样式和模板](https://learn.microsoft.com/zh-cn/dotnet/desktop/wpf/controls/styles-templates-overview)
- [Windows 主题检测](https://learn.microsoft.com/zh-cn/windows/apps/desktop/modernize/apply-windows-themes)
- 综合示例:[11-FullStackDemo](../11-FullStackDemo/README.md)
---
**打造美观现代的用户界面!** 🎨
+569
View File
@@ -0,0 +1,569 @@
# 11 - Full Stack Demo - 完整功能演示
这是一个综合示例,集成了 Sharp 框架的几乎所有功能,演示如何构建一个完整的工业监控系统。
## 🎯 功能列表
### 核心功能
- ✅ 用户登录(数据库验证)
- ✅ 多页面导航
- ✅ 主题切换(浅色/深色)
- ✅ 多语言支持(中英文)
- ✅ 表单验证
### 数据管理
- ✅ 产品 CRUD 操作
- ✅ 数据分页显示
- ✅ 缓存优化
### 工业IoT
- ✅ **PLC 通信测试** - 可配置 IP 和参数进行测试
- ✅ **串口通信测试** - 可选择串口和配置参数
### 其他
- ✅ 配置热重载
- ✅ 日志记录
- ✅ HTTP 客户端调用
## 📦 使用的模块
- Sharp.Common
- Sharp.Config
- Sharp.Wpf
- Sharp.Data
- Sharp.Cache
- Sharp.Log
- Sharp.Plc.Siemens
- Sharp.Ports
- Sharp.NetClient
## 🚀 快速运行
```bash
cd examples/11-FullStackDemo
dotnet run
```
## 📝 项目结构
```
11-FullStackDemo/
├── App.xaml.cs # 应用启动配置
├── appsettings.json # 配置文件
├── Models/
│ ├── User.cs # 用户实体
│ └── Product.cs # 产品实体
├── ViewModels/
│ ├── LoginViewModel.cs # 登录页
│ ├── MainViewModel.cs # 主窗口
│ ├── HomeViewModel.cs # 首页(仪表板)
│ ├── ProductsViewModel.cs # 产品管理
│ ├── PlcTestViewModel.cs # PLC 测试页 ⭐
│ ├── PortsTestViewModel.cs# 串口测试页 ⭐
│ └── SettingsViewModel.cs # 设置页
├── Views/
│ ├── LoginWindow.xaml
│ ├── MainWindow.xaml
│ ├── HomeView.xaml
│ ├── ProductsView.xaml
│ ├── PlcTestView.xaml # PLC 测试界面 ⭐
│ ├── PortsTestView.xaml # 串口测试界面 ⭐
│ └── SettingsView.xaml
└── Data/
└── AppDbContext.cs
```
## 🔧 核心代码讲解
### 1. App.xaml.cs - 完整启动配置
```csharp
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var services = new ServiceCollection();
// 1. 配置管理
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSingleton(config);
// 2. 数据库
services.AddSharpDatabase<AppDbContext>(config);
// 3. WPF 服务(包括导航、主题、国际化)
services.AddSharpWpf();
services.AddLocalization(Resources.Strings.ResourceManager,
new CultureInfo("zh-CN"),
new CultureInfo("en-US"));
// 4. 缓存
services.AddSharpCache();
// 5. 日志
services.AddLogging(builder => {
builder.AddConsole();
builder.AddDebug();
});
// 6. PLC 服务(可选 - 如果配置中启用)
var plcEnabled = config.GetValue<bool>("Plc:Enabled", false);
if (plcEnabled)
{
services.AddSiemensPlc(config.GetSection("Plc:Siemens"));
}
// 7. 串口服务
services.AddSingleton<ISerialPortService, SerialPortService>();
// 8. HTTP 客户端
services.AddHttpClient();
// 9. 注册 ViewModels
services.AddViewModel<LoginViewModel>();
services.AddViewModel<MainViewModel>();
services.AddViewModel<HomeViewModel>();
services.AddViewModel<ProductsViewModel>();
services.AddViewModel<PlcTestViewModel>();
services.AddViewModel<PortsTestViewModel>();
services.AddViewModel<SettingsViewModel>();
// 10. 注册 Windows
services.AddTransient<LoginWindow>();
services.AddTransient<MainWindow>();
_serviceProvider = services.BuildServiceProvider();
// 11. 初始化数据库
using (var scope = _serviceProvider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
SeedData(db); // 初始化测试数据
}
// 12. 应用主题
var themeService = _serviceProvider.GetRequiredService<IThemeService>();
var savedTheme = config.GetValue<string>("App:Theme", "Light");
themeService.ApplyTheme(Enum.Parse<Theme>(savedTheme));
// 13. 显示登录窗口
ShowLoginWindow();
}
```
### 2. PlcTestViewModel.cs - PLC 测试页 ⭐
```csharp
public class PlcTestViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private IPlcService? _plcService;
// 连接参数
private string _ipAddress = "192.168.1.100";
private int _rack = 0;
private int _slot = 1;
private bool _isConnected;
public string IpAddress
{
get => _ipAddress;
set => SetProperty(ref _ipAddress, value);
}
public int Rack
{
get => _rack;
set => SetProperty(ref _rack, value);
}
public int Slot
{
get => _slot;
set => SetProperty(ref _slot, value);
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
// 读写测试
private string _address = "DB1.DBD0";
private string _value = "0";
private string _readResult = "";
public string Address
{
get => _address;
set => SetProperty(ref _address, value);
}
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
public string ReadResult
{
get => _readResult;
set => SetProperty(ref _readResult, value);
}
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand ReadCommand { get; }
public ICommand WriteCommand { get; }
public PlcTestViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
ConnectCommand = new AsyncCommand(async (ct) =>
{
try
{
IsBusy = true;
// 动态创建 PLC 服务
var options = new SiemensPlcOptions
{
IpAddress = IpAddress,
Rack = Rack,
Slot = Slot,
CpuType = CpuType.S71500
};
_plcService = new PlcMonitorService(
new SiemensDriver(options),
new SiemensOptimizer(),
new BigEndianConverter(),
Options.Create(new PlcMonitorOptions()),
null);
await _plcService.StartAsync(ct);
IsConnected = true;
_dialogService.ShowMessage("PLC 连接成功!", "成功");
}
catch (Exception ex)
{
_dialogService.ShowMessage($"连接失败: {ex.Message}", "错误");
}
finally
{
IsBusy = false;
}
});
DisconnectCommand = new AsyncCommand(async (ct) =>
{
if (_plcService != null)
{
await _plcService.StopAsync();
IsConnected = false;
_dialogService.ShowMessage("已断开连接", "提示");
}
});
ReadCommand = new AsyncCommand(async (ct) =>
{
if (_plcService == null)
{
_dialogService.ShowMessage("请先连接 PLC", "提示");
return;
}
try
{
IsBusy = true;
var result = await _plcService.ReadAsync<int>(Address);
if (result.IsSuccess)
{
ReadResult = $"读取成功: {result.Value}";
}
else
{
ReadResult = $"读取失败: {result.ErrorMessage}";
}
}
catch (Exception ex)
{
ReadResult = $"读取异常: {ex.Message}";
}
finally
{
IsBusy = false;
}
});
WriteCommand = new AsyncCommand(async (ct) =>
{
if (_plcService == null)
{
_dialogService.ShowMessage("请先连接 PLC", "提示");
return;
}
try
{
IsBusy = true;
var intValue = int.Parse(Value);
var result = await _plcService.WriteAsync(Address, intValue);
if (result.IsSuccess)
{
_dialogService.ShowMessage("写入成功!", "成功");
}
else
{
_dialogService.ShowMessage($"写入失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"写入异常: {ex.Message}", "错误");
}
finally
{
IsBusy = false;
}
});
}
}
```
### 3. PortsTestViewModel.cs - 串口测试页 ⭐
```csharp
public class PortsTestViewModel : ViewModelBase
{
private readonly ISerialPortService _serialPortService;
private readonly IDialogService _dialogService;
// 可用串口列表
public ObservableCollection<string> AvailablePorts { get; } = new();
private string? _selectedPort;
private int _baudRate = 9600;
private bool _isConnected;
public string? SelectedPort
{
get => _selectedPort;
set => SetProperty(ref _selectedPort, value);
}
public int BaudRate
{
get => _baudRate;
set => SetProperty(ref _baudRate, value);
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
// 发送/接收
private string _sendData = "";
private string _receiveData = "";
public string SendData
{
get => _sendData;
set => SetProperty(ref _sendData, value);
}
public string ReceiveData
{
get => _receiveData;
set => SetProperty(ref _receiveData, value);
}
public ICommand RefreshPortsCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand SendCommand { get; }
public ICommand ClearCommand { get; }
public PortsTestViewModel(
ISerialPortService serialPortService,
IDialogService dialogService)
{
_serialPortService = serialPortService;
_dialogService = dialogService;
// 监听数据接收
_serialPortService.DataReceived += (s, e) =>
{
Application.Current.Dispatcher.Invoke(() =>
{
ReceiveData += $"[{DateTime.Now:HH:mm:ss}] 接收: {BitConverter.ToString(e.Data)}\n";
});
};
RefreshPortsCommand = new RelayCommand(() =>
{
AvailablePorts.Clear();
var ports = SerialPortDiscovery.GetAvailablePorts();
foreach (var port in ports)
{
AvailablePorts.Add(port.PortName);
}
if (AvailablePorts.Count > 0)
{
SelectedPort = AvailablePorts[0];
}
});
ConnectCommand = new AsyncCommand(async (ct) =>
{
if (string.IsNullOrEmpty(SelectedPort))
{
_dialogService.ShowMessage("请选择串口", "提示");
return;
}
try
{
var options = new SerialPortOptions
{
PortName = SelectedPort,
BaudRate = BaudRate
};
var result = await _serialPortService.StartListeningAsync(options, autoReconnect: false);
if (result.IsSuccess)
{
IsConnected = true;
_dialogService.ShowMessage("串口连接成功!", "成功");
}
else
{
_dialogService.ShowMessage($"连接失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"连接异常: {ex.Message}", "错误");
}
});
DisconnectCommand = new AsyncCommand(async (ct) =>
{
await _serialPortService.StopListeningAsync();
IsConnected = false;
_dialogService.ShowMessage("已断开连接", "提示");
});
SendCommand = new AsyncCommand(async (ct) =>
{
if (!IsConnected)
{
_dialogService.ShowMessage("请先连接串口", "提示");
return;
}
try
{
// 将字符串转换为字节数组
var data = Encoding.UTF8.GetBytes(SendData);
var result = await _serialPortService.SendAsync(data);
if (result.IsSuccess)
{
ReceiveData += $"[{DateTime.Now:HH:mm:ss}] 发送: {SendData}\n";
}
else
{
_dialogService.ShowMessage($"发送失败: {result.ErrorMessage}", "错误");
}
}
catch (Exception ex)
{
_dialogService.ShowMessage($"发送异常: {ex.Message}", "错误");
}
});
ClearCommand = new RelayCommand(() =>
{
ReceiveData = "";
});
// 初始化:刷新串口列表
RefreshPortsCommand.Execute(null);
}
}
```
### 4. appsettings.json - 配置文件
```json
{
"App": {
"Theme": "Light",
"Language": "zh-CN"
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=demo.db"
},
"Plc": {
"Enabled": false,
"Siemens": {
"IpAddress": "192.168.1.100",
"Port": 102,
"CpuType": "S71500",
"Rack": 0,
"Slot": 1
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
}
}
}
```
## 🎓 使用说明
### 测试 PLC 连接
1. 进入"PLC 测试"页面
2. 输入 PLC 的 IP 地址、Rack、Slot
3. 点击"连接"按钮
4. 连接成功后,输入地址(如 `DB1.DBD0`)
5. 点击"读取"或"写入"测试通信
### 测试串口通信
1. 进入"串口测试"页面
2. 点击"刷新端口"查看可用串口
3. 选择串口并设置波特率
4. 点击"连接"
5. 在发送框输入数据,点击"发送"
6. 接收框会显示收到的数据
## 💡 提示
- PLC 和串口功能可以独立测试,无需实际设备(会显示连接失败,但不影响其他功能)
- 数据库使用 SQLite,无需安装
- 配置文件支持热重载,修改后自动生效
- 所有功能都有错误处理,不会导致程序崩溃
## 📚 延伸阅读
- 所有示例项目的 README
- Sharp 框架文档
---
**这是一个完整的工业监控系统模板,可以直接用于实际项目开发!** 🏭
+273
View File
@@ -0,0 +1,273 @@
# Sharp Framework Examples
本目录包含 Sharp 框架各个功能的示例项目,帮助你快速上手。
## 📚 示例项目列表
### 基础示例
#### 1. **01-MinimalWpfApp** - 最小 WPF 应用 ⭐
**演示内容**:
- 最简单的 Sharp WPF 应用
- 仅使用 Common + Wpf 模块
- MVVM 基础(ViewModelBase, Commands)
- 对话框服务
**适用场景**:刚接触 Sharp 框架,想快速了解基本用法
**运行**:
```bash
cd examples/01-MinimalWpfApp
dotnet run
```
---
#### 2. **02-WpfWithDatabase** - WPF + 数据库 ⭐⭐
**演示内容**:
- WPF + SQLite 数据库
- Repository 模式(CRUD操作)
- 数据绑定与列表展示
- 添加/编辑/删除产品
**适用场景**:需要本地数据存储的桌面应用
**技术栈**:Common, Config, Wpf, Data (SQLite)
---
#### 3. **03-FormValidation** - 表单验证 ⭐⭐
**演示内容**:
- ValidatableViewModelBase 使用
- DataAnnotations 验证
- 实时验证反馈
- 错误消息显示
**适用场景**:需要用户输入验证的应用
---
#### 4. **04-Navigation** - 导航系统 ⭐⭐
**演示内容**:
- INavigationService 使用
- 多页面导航
- 前进/后退功能
- 页面间参数传递
- 导航生命周期(INavigationAware)
**适用场景**:多页面 WPF 应用
---
#### 5. **05-Localization** - 国际化 ⭐⭐
**演示内容**:
- 多语言支持(中文、英文)
- LocalizeExtension XAML 绑定
- 运行时切换语言
- 资源文件管理
**适用场景**:需要多语言支持的应用
---
#### 6. **06-Theming** - 主题系统 ⭐⭐
**演示内容**:
- 浅色/深色主题切换
- 自定义主题颜色
- 跟随系统主题
- 动态主题切换
**适用场景**:需要主题切换的现代应用
---
### 工业IoT示例
#### 7. **07-PlcMonitoring** - PLC 监控 ⭐⭐⭐
**演示内容**:
- 连接西门子 PLC
- 实时数据监控
- 数据变化订阅
- 读写PLC标签
- 数据可视化(图表)
**适用场景**:工业自动化、设备监控
**技术栈**:Wpf, Plc.Siemens, Cache(缓存历史数据)
---
#### 8. **08-SerialPortComm** - 串口通信 ⭐⭐
**演示内容**:
- 串口自动发现
- 数据收发
- HEX/ASCII 显示
- 数据记录
**适用场景**:串口设备通信、调试工具
**技术栈**:Wpf, Ports
---
### 网络示例
#### 9. **09-HttpServer** - 嵌入式 Web 服务器 ⭐⭐⭐
**演示内容**:
- 在 WPF 应用中嵌入 HTTP 服务器
- RESTful API 实现
- 中间件使用(CORS, 认证, 日志)
- WPF 界面管理服务器
**适用场景**:设备管理后台、本地Web服务
**技术栈**:Wpf, NetServer, Log
---
#### 10. **10-HttpClient** - HTTP 客户端 ⭐⭐
**演示内容**:
- 调用外部 API
- 重试策略(Polly)
- Result<T> 错误处理
- 缓存 API 响应
**适用场景**:需要调用外部服务的应用
**技术栈**:Wpf, NetClient, Cache
---
### 综合示例
#### 11. **11-FullStackDemo** - 完整功能演示 ⭐⭐⭐⭐
**演示内容**:
- 用户登录(数据库验证)
- 产品管理(CRUD)
- PLC 数据监控
- 串口通信
- 数据报表导出
- 多语言
- 主题切换
- 配置热重载
**适用场景**:工业监控系统、MES系统、数采系统
**技术栈**:全部模块
---
#### 12. **12-CachingDemo** - 缓存使用 ⭐
**演示内容**:
- 字典数据缓存
- API 响应缓存
- 计算结果缓存
- 缓存失效策略
**技术栈**:Wpf, Cache, NetClient
---
#### 13. **13-EventBusDemo** - 事件总线 ⭐
**演示内容**:
- 跨模块通信
- 事件发布/订阅
- 解耦架构
**技术栈**:Wpf, EventBus
---
## 🚀 快速开始
### 运行示例
```bash
# 1. 克隆仓库
git clone https://github.com/your-repo/sharp-basic.git
cd sharp-basic/examples
# 2. 选择一个示例
cd 01-MinimalWpfApp
# 3. 还原依赖并运行
dotnet restore
dotnet run
```
### 从示例创建新项目
```bash
# 复制示例作为起点
cp -r examples/02-WpfWithDatabase MyNewProject
cd MyNewProject
# 修改项目名称
# 编辑 .csproj 文件和命名空间
# 开始开发!
```
---
## 📖 学习路径
### 新手推荐路径
1. **01-MinimalWpfApp** - 了解基础
2. **02-WpfWithDatabase** - 学习数据库操作
3. **03-FormValidation** - 掌握表单验证
4. **04-Navigation** - 理解导航系统
### 工业自动化开发者
1. **07-PlcMonitoring** - PLC 通信
2. **08-SerialPortComm** - 串口通信
3. **11-FullStackDemo** - 完整系统
### Web 开发者转 WPF
1. **09-HttpServer** - HTTP 服务器
2. **10-HttpClient** - HTTP 客户端
3. **06-Theming** - 主题系统
---
## 💡 示例代码说明
所有示例都遵循:
- ✅ **最佳实践**:展示推荐的使用方式
- ✅ **完整代码**:可直接运行,无需额外配置
- ✅ **详细注释**:中文注释,解释关键代码
- ✅ **README**:每个示例都有独立的 README
---
## 🛠️ 技术要求
- .NET 8.0 SDK
- Visual Studio 2022 或 VS Code (推荐安装 C# Dev Kit)
- Windows 10/11 (WPF 应用需要 Windows)
---
## 📝 贡献示例
欢迎贡献新的示例!请确保:
1. 代码简洁明了
2. 包含 README 说明
3. 注释使用中文
4. 可独立运行
---
## ❓ 常见问题
**Q: 示例可以直接用于生产吗?**
A: 示例代码主要用于学习,生产使用需要根据实际情况调整(如安全性、错误处理、日志等)。
**Q: 示例使用什么数据库?**
A: 大部分示例使用 SQLite(无需安装,开箱即用),也有 SQL Server 示例。
**Q: 可以在 .NET Framework 上运行吗?**
A: 不可以,Sharp 框架基于 .NET 8,需要 .NET 8 运行时。
---
**选择一个示例开始你的 Sharp 之旅吧!** 🎉
+346
View File
@@ -0,0 +1,346 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sharp-Basic is a modular C# .NET 8 framework for building WPF and C# applications with reusable components. It provides a comprehensive toolkit with emphasis on:
- **Modularity**: Independent, reusable libraries that can be used selectively - "引入即用"
- **Quick Start**: Zero-configuration setup with sensible defaults - "约定优于配置"
- **Desktop Applications**: WPF-based UI toolkit with MVVM infrastructure
- **Industrial IoT**: PLC integration (Siemens) and serial port communication (optional)
- **Configuration as Code**: Multi-format (JSON/XML/INI/TOML) with hot-reload
- **Functional Programming**: Result<T> pattern for railway-oriented error handling
- **Opinionated**: Strong technology choices (EF Core, Serilog, etc.) for team consistency
**Design Philosophy**: "固执己见的框架" - Makes strong choices about technology stack to enable fast development. If you can't use EF Core, this framework may not be for you.
**Language**: All comments and documentation are in Chinese (中文).
## Quick Start
**New to Sharp?** See `QUICKSTART.md` for zero-configuration setup guide.
### Minimal WPF Application (3 lines)
```csharp
var services = new ServiceCollection();
services.AddSharpEssentials(); // Config + Logging + Cache
services.AddSharpWpf(); // WPF Services (Dialog, Dispatcher)
services.AddViewModel<MainViewModel>();
```
### With Database (1 more line)
```csharp
services.AddSharpDatabase<AppDbContext>(config); // Reads from appsettings.json
```
## Build Commands
### Build the entire solution
```bash
cd src/Sharp
dotnet build Sharp.sln
```
### Build specific project
```bash
cd src/Sharp
dotnet build Sharp.Demo/Sharp.Demo.csproj # WPF demo application (最佳实践示例)
dotnet build Common/Common.csproj # Core utilities
dotnet build Config/Config.csproj # Configuration management
```
### Run tests
```bash
cd src/Sharp
dotnet test Sharp.sln # All tests
dotnet test ../../test/Common.Test # Specific test project
```
### Run the Sharp.Demo application
```bash
cd src/Sharp
dotnet run --project Sharp.Demo/Sharp.Demo.csproj
```
### Clean build artifacts
```bash
cd src/Sharp
dotnet clean Sharp.sln
```
## Architecture Overview
### Project Structure
```
src/Sharp/ # All source projects
├── Common/ # Core foundation (Result<T>, Global container, utilities, SystemConfig)
├── Config/ # Multi-format configuration with hot-reload
├── Wpf/ # WPF UI toolkit (MVVM, dialogs, converters) - NO UI library dependency
├── Wpf.HandyControl/ # HandyControl UI library integration (optional)
├── Wpf.DevTools/ # WPF Developer Tools (DevTools, LogViewer, Performance Monitor)
├── Data/ # EF Core wrapper with repository pattern
├── Log/ # Serilog-based structured logging
├── Cache/ # Memory cache (planned)
├── EventBus/ # Event pub/sub (planned)
├── NetClient/ # HTTP client with Polly resilience
├── NetServer/ # Lightweight HTTP server with middleware
├── Ports/ # Serial port abstraction (RS232/RS485)
├── Plc/ # PLC abstraction interface
├── Plc.Siemens/ # Siemens S7 PLC implementation (S7.NET+)
├── Process/ # Process execution
├── Python/ # Python script execution
└── Sharp.Demo/ # 综合演示应用(最佳实践示例,替代了 Client 和 Server)
test/ # MSTest test projects
├── Common.Test/
├── Config.Test/
├── Data.Test/
├── Log.Test/
├── NetClient.Test/
└── NetServer.Test/
../../appsettings.template.json # 完整的配置模板文件(约定配置节名称)
```
### Key Architectural Patterns
#### 1. Result<T> Pattern (Railway-Oriented Programming)
- Eliminates exceptions as control flow
- Provides chainable operations: `Map`, `Bind`, `Match`, `Where`, `OnSuccess`, `OnFailure`
- Synchronous and async wrappers: `Result.Try()`, `Result.TryAsync()`
- Example usage:
```csharp
Result.Try(() => int.Parse(input))
.Map(x => x * 2)
.Where(v => v > 100, "Value too small")
.OnSuccess(v => Console.WriteLine(v))
.OnFailure((ex, msg) => logger.LogError(msg));
```
#### 2. Dependency Injection (Hybrid Approach)
- **Primary**: Microsoft.Extensions.DependencyInjection for standard DI
- **Fallback**: Global service locator (Common.Global) for legacy/convenience
- Services registered in `DependencyInjection` folders within each module
- Sharp.Demo app startup (App.xaml.cs) orchestrates all service registration
#### 3. Configuration Management
- Multi-source configuration builder supporting JSON/XML/INI/TOML
- Hot-reload with change notifications
- Strong typing with DataAnnotations validation
- IOptions<T> pattern integration
- Environment variable overrides with optional prefix
#### 4. Repository Pattern with EF Core
- Generic `IRepository<TEntity>` abstraction
- Built-in entity types: `Entity`, `AuditableEntity`, `ISoftDeletable`
- Auto-audit tracking (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
- Multi-database support: SQL Server, SQLite, PostgreSQL, MySQL, In-Memory
#### 5. MVVM for WPF
- `ViewModelBase` with INotifyPropertyChanged and IsBusy tracking
- `RelayCommand` and `AsyncCommand` implementations
- Services: `IDialogService`, `IFileDialogService`, `IDispatcherService`
- Converters: BoolToVisibility, InverseBooleanConverter, EnumToDescriptionConverter
- Global exception handling for UI/Task/AppDomain exceptions
#### 6. Middleware Pattern
- Custom HTTP server (NetServer) with ASP.NET Core-style pipeline
- Middleware: Authentication, CORS, Rate Limiting, Caching, Logging, Exception Handling
- Attribute-based routing with `RouteAttribute`
- Custom `IMiddleware` interface for extensibility
## Module Dependencies
**Core Layer** (no external dependencies):
- Common → (no dependencies)
**Configuration Layer**:
- Config → Microsoft.Extensions.Configuration.*, Tomlyn (TOML support)
**Data Layer**:
- Data → EF Core, Common
- Repository → Data
**Network Layer**:
- NetClient → Polly, Microsoft.Extensions.Http, Common
- NetServer → Common, Log
**WPF Layer**:
- Wpf → (no UI library dependency - pure MVVM)
- Wpf.HandyControl → Wpf, HandyControl 3.5.0 (optional UI library integration)
- Wpf.DevTools → Wpf (Developer Tools: DevTools, LogViewer, Performance Monitor)
- Sharp.Demo → Wpf, Wpf.HandyControl, Wpf.DevTools, Config, NetClient, NetServer, Log, Data, Plc.Siemens, Ports, Process, Python
**Industrial IoT**:
- Ports → (serial port communication)
- Plc → (abstraction only)
- Plc.Siemens → Plc, S7.NET+ library
**Logging**:
- Log → Serilog, Microsoft.Extensions.Logging
## Common Development Workflows
### Quick Start for New Projects (Recommended)
1. Install NuGet packages (or reference projects)
2. Create `appsettings.json` in project root
3. Use Quick Start extensions in `App.xaml.cs`:
```csharp
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
// Option 1: Minimal (Config + Log + Cache + WPF)
services.AddSharpEssentials();
services.AddSharpWpf();
// Option 2: With Database
var config = ConfigBuilder.CreateDefault().Configuration;
services.AddSharpEssentials(config);
services.AddSharpDatabase<AppDbContext>(config); // Reads from appsettings.json
services.AddSharpWpf();
// Register ViewModels
services.AddViewModel<MainViewModel>();
var provider = services.BuildServiceProvider();
// Show main window...
}
```
4. See `QUICKSTART.md` for complete examples
### Adding a new module/library
1. Create project under `src/Sharp/`
2. Target `net8.0` (or `net8.0-windows` for WPF)
3. Enable nullable reference types: `<Nullable>enable</Nullable>`
4. Create `DependencyInjection` folder for service registration extensions with `AddSharpXxx()` methods
5. Create comprehensive `README.md` with Quick Start section
6. Add test project under `test/` with MSTest
7. Add to Sharp.sln: `dotnet sln add <ProjectPath>`
### Working with Result<T>
- Use `Result.Try()` to wrap exception-prone code
- Chain operations with `Map()` for transformations
- Use `Bind()` for operations that return Result<T>
- Extract values with `GetOrDefault()`, `GetOrThrow()`, or `Match()`
- Always handle both success and failure cases
### Using the Global container
- Register singletons: `Global.RegisterSingleton<T>(instance)` or `Global.RegisterSingleton<T>(() => new T())`
- Register transients: `Global.RegisterTransient<T>(() => new T())`
- Subscribe to changes: `Global.Subscribe<T>(e => { /* handle change */ })`
- Retrieve services: `Global.Get<T>()`
- **Note**: Prefer standard DI (IServiceCollection) for new code; Global is for legacy/compatibility
### Configuration workflow (Quick Start)
1. Create `appsettings.json` in project root
2. Use default configuration:
```csharp
var config = ConfigBuilder.CreateDefault().Configuration;
```
3. Configuration automatically loaded with:
- Hot-reload enabled
- Environment variables (prefix: `APP_`)
- Environment-specific files (appsettings.Development.json, etc.)
### Configuration workflow (Manual)
1. Add config files to project root (appsettings.json, etc.)
2. Create strongly-typed options class with DataAnnotations
3. Build ConfigManager: `ConfigBuilder.BuildFromFiles(...)`
4. Register with DI: `services.AddSingleton(cfg)` and `services.AddSingleton(cfg.Configuration)`
5. Use hot-reload callbacks: `cfg.OnReload(() => { /* refresh */ })`
### WPF Sharp.Demo startup flow (Modern Quick Start)
```csharp
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
var config = ConfigBuilderExtensions.CreateDefault().Configuration;
services.AddSharpEssentials(config);
services.AddSharpWpf();
services.AddSharpHandyControl();
services.AddSharpDevTools();
services.AddViewModel<LauncherViewModel>();
services.AddTransient<LauncherWindow>();
var provider = services.BuildServiceProvider();
provider.GetRequiredService<LauncherWindow>().Show();
}
```
### Working with PLC (Siemens)
- Interface: `IPlcService` in Plc project
- Implementation: Plc.Siemens using S7.NET+ library
- Supports: S7-1200, S7-1500, S7-300, S7-400
- Register with DI: `services.AddSiemensPlc(config.GetSection("Plc:Siemens"))`
- Methods: Read/Write memory areas, data blocks, Subscribe for monitoring
### Serial Port Communication
- Service: `ISerialPortService` in Ports module
- Discovery: `SerialPortDiscovery` for auto-detection
- Event-based data reception: `DataReceivedEventArgs`
- Configured with: baud rate, parity, stop bits, data bits
### Using Cache (When Implemented)
```csharp
// Dictionary data caching
var categories = await _cache.GetOrSetAsync(
"categories:all",
() => _repository.GetAllAsync(),
TimeSpan.FromMinutes(30)
);
// API response caching
var weather = await _cache.GetOrSetAsync(
$"weather:{city}",
() => _apiClient.GetWeatherAsync(city),
TimeSpan.FromMinutes(5)
);
```
### Using EventBus (When Implemented)
```csharp
// Publish
_eventBus.Publish(new UserLoginEvent(username, DateTime.Now));
// Subscribe
_eventBus.Subscribe<UserLoginEvent>(e => {
WelcomeMessage = $"欢迎, {e.Username}!";
});
```
## Code Style
- **Target Framework**: .NET 8 (net8.0 / net8.0-windows)
- **Nullable**: Enabled (`#nullable enable`)
- **Implicit Usings**: Enabled
- **Namespace Convention**: `Sharp.*` (Common will migrate to `Sharp.Common`)
- **Language**: All comments, docs, and strings in Chinese
- **Backwards Compatibility**: Maintain for minor versions; breaking changes only in major versions
- **Testing**: MSTest with parallel execution enabled
## Design Principles
1. **"引入即用" (Plug and Play)**: Every module should work with minimal configuration
2. **"约定优于配置" (Convention over Configuration)**: Sensible defaults for everything
3. **"固执己见" (Opinionated)**: Strong technology choices (EF Core, Serilog, HandyControl)
- If you can't use our stack, this framework is not for you
- Designed for team/personal consistency, not maximum flexibility
4. **Quick Start First**: Every module README must have a "Quick Start" section showing 1-line setup
5. **No Abstraction Overkill**: Don't create interfaces just for flexibility - commit to implementations
## Module README Template
Every module must have a README with:
1. **Quick Start** section (5-second setup)
2. **Why You Need This** section (problem/solution)
3. **Core API** section (main interfaces/classes)
4. **Typical Scenarios** section (3-5 real-world examples)
5. **Configuration** section (if applicable)
See `EventBus/README.md` and `Cache/README.md` for examples (even though not implemented yet).
## License
GNU AGPLv3 - Network services that use this framework must open source modifications. Contact author for alternative commercial licensing.
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Sharp.Cache</AssemblyName>
<RootNamespace>Sharp.Cache</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>
+84
View File
@@ -0,0 +1,84 @@
using System;
namespace Sharp.Cache;
/// <summary>
/// 缓存扩展方法
/// </summary>
public static class CacheExtensions
{
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(同步)
/// </summary>
public static T GetOrSet<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
var value = factory();
cache.Set(key, value, expiration);
return value;
}
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(异步)
/// </summary>
public static async Task<T> GetOrSetAsync<T>(
this ICacheService cache,
string key,
Func<Task<T>> factory,
TimeSpan? expiration = null)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
var value = await factory();
cache.Set(key, value, expiration);
return value;
}
/// <summary>
/// 获取或设置(滑动过期)
/// </summary>
public static T GetOrSetSliding<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan slidingExpiration)
{
if (cache.TryGet<T>(key, out var cached))
{
return cached!;
}
var value = factory();
cache.SetSliding(key, value, slidingExpiration);
return value;
}
/// <summary>
/// 批量移除(支持通配符)
/// </summary>
public static void RemoveByPattern(this ICacheService cache, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
return;
if (!pattern.EndsWith("*", StringComparison.Ordinal))
{
cache.Remove(pattern);
return;
}
var prefix = pattern.TrimEnd('*');
cache.RemoveByPrefix(prefix);
}
}
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
namespace Sharp.Cache.DependencyInjection;
/// <summary>
/// 缓存服务注册扩展
/// </summary>
public static class CacheServiceExtensions
{
/// <summary>
/// 添加 Sharp 内存缓存服务
/// </summary>
public static IServiceCollection AddSharpCache(this IServiceCollection services)
{
// 注册 MemoryCache
services.AddMemoryCache();
// 注册 ICacheService
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
/// <summary>
/// 添加 Sharp 内存缓存服务(带配置)
/// </summary>
public static IServiceCollection AddSharpCache(
this IServiceCollection services,
Action<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> configure)
{
// 注册 MemoryCache 并配置
services.AddMemoryCache(configure);
// 注册 ICacheService
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
}
+183
View File
@@ -0,0 +1,183 @@
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
namespace Sharp.Cache;
/// <summary>
/// 缓存服务接口
/// </summary>
public interface ICacheService
{
/// <summary>
/// 获取缓存值
/// </summary>
T? Get<T>(string key);
/// <summary>
/// 尝试获取缓存值
/// </summary>
bool TryGet<T>(string key, out T? value);
/// <summary>
/// 设置缓存
/// </summary>
void Set<T>(string key, T value, TimeSpan? expiration = null);
/// <summary>
/// 设置缓存(滑动过期)
/// </summary>
void SetSliding<T>(string key, T value, TimeSpan slidingExpiration);
/// <summary>
/// 移除缓存
/// </summary>
void Remove(string key);
/// <summary>
/// 清空所有缓存
/// </summary>
void Clear();
/// <summary>
/// 按前缀批量移除
/// </summary>
void RemoveByPrefix(string prefix);
/// <summary>
/// 已缓存键集合(只读)
/// </summary>
IReadOnlyCollection<string> Keys { get; }
/// <summary>
/// 检查缓存是否存在
/// </summary>
bool Exists(string key);
}
/// <summary>
/// 基于 MemoryCache 的缓存服务实现
/// </summary>
public class MemoryCacheService : ICacheService
{
private readonly IMemoryCache _cache;
private readonly HashSet<string> _keys = new(); // 跟踪所有键(用于 Clear)
private readonly object _lockObj = new();
public MemoryCacheService(IMemoryCache cache)
{
_cache = cache;
}
public T? Get<T>(string key)
{
return _cache.Get<T>(key);
}
public bool TryGet<T>(string key, out T? value)
{
return _cache.TryGetValue(key, out value);
}
public void Set<T>(string key, T value, TimeSpan? expiration = null)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromHours(1)
};
options.RegisterPostEvictionCallback((k, v, r, s) =>
{
lock (_lockObj)
{
_keys.Remove(k.ToString() ?? "");
}
});
_cache.Set(key, value, options);
lock (_lockObj)
{
_keys.Add(key);
}
}
public void SetSliding<T>(string key, T value, TimeSpan slidingExpiration)
{
var options = new MemoryCacheEntryOptions
{
SlidingExpiration = slidingExpiration
};
options.RegisterPostEvictionCallback((k, v, r, s) =>
{
lock (_lockObj)
{
_keys.Remove(k.ToString() ?? "");
}
});
_cache.Set(key, value, options);
lock (_lockObj)
{
_keys.Add(key);
}
}
public void Remove(string key)
{
_cache.Remove(key);
lock (_lockObj)
{
_keys.Remove(key);
}
}
public void Clear()
{
lock (_lockObj)
{
foreach (var key in _keys.ToList())
{
_cache.Remove(key);
}
_keys.Clear();
}
}
public void RemoveByPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return;
}
List<string> targets;
lock (_lockObj)
{
targets = _keys.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
}
foreach (var key in targets)
{
Remove(key);
}
}
public IReadOnlyCollection<string> Keys
{
get
{
lock (_lockObj)
{
return _keys.ToList().AsReadOnly();
}
}
}
public bool Exists(string key)
{
return _cache.TryGetValue(key, out _);
}
}
+623
View File
@@ -0,0 +1,623 @@
# Sharp.Cache
> **🚧 开发中 / Under Construction**
轻量级内存缓存模块,基于 `Microsoft.Extensions.Caching.Memory`,提供简洁的缓存API。
## 📋 为什么需要 Cache?
### 场景对比
#### ❌ 不使用 Cache - 重复查询
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
// 问题:每次打开编辑对话框都查询数据库
public async Task<ProductEditViewModel> GetEditViewModelAsync(int productId) {
var product = await _repository.GetByIdAsync(productId);
// 分类数据很少变化,但每次都查询
var categories = await _categoryRepo.GetAllAsync();
return new ProductEditViewModel {
Product = product,
Categories = categories
};
}
}
```
#### ✅ 使用 Cache - 智能缓存
```csharp
public class ProductService {
private readonly IRepository<Category> _categoryRepo;
private readonly ICacheService _cache;
public async Task<ProductEditViewModel> GetEditViewModelAsync(int productId) {
var product = await _repository.GetByIdAsync(productId);
// 从缓存获取,不存在则查询并缓存30分钟
var categories = await _cache.GetOrSetAsync(
"categories:all",
() => _categoryRepo.GetAllAsync(),
TimeSpan.FromMinutes(30)
);
return new ProductEditViewModel {
Product = product,
Categories = categories
};
}
}
```
**效果**:
- 第一次:查询数据库(耗时 50ms)
- 后续访问:从内存读取(耗时 < 1ms)
- 用户体验:界面秒开,不再等待
---
## 🎯 设计目标
1. **极简API** - 一行代码搞定90%的缓存需求
2. **类型安全** - 强类型缓存,编译期检查
3. **自动过期** - 支持绝对过期和滑动过期
4. **引入即用** - 基于 .NET 内置 MemoryCache,零配置
5. **线程安全** - 完全线程安全
---
## 🚀 快速开始(未来)
### 1. 注册服务
```csharp
// App.xaml.cs
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
// 一行注册(基于 Microsoft.Extensions.Caching.Memory)
services.AddMemoryCache();
var provider = services.BuildServiceProvider();
}
```
### 2. 注入使用
```csharp
public class WeatherService {
private readonly ICacheService _cache;
private readonly IHttpClient _httpClient;
public WeatherService(ICacheService cache, IHttpClient httpClient) {
_cache = cache;
_httpClient = httpClient;
}
public async Task<Weather> GetWeatherAsync(string city) {
// 缓存5分钟,避免频繁调用外部API
return await _cache.GetOrSetAsync(
$"weather:{city}",
() => _httpClient.GetAsync<Weather>($"/api/weather?city={city}"),
TimeSpan.FromMinutes(5)
);
}
}
```
---
## 📚 核心 API 设计
### ICacheService 接口
```csharp
public interface ICacheService {
/// <summary>
/// 获取缓存值
/// </summary>
T? Get<T>(string key);
/// <summary>
/// 尝试获取缓存值
/// </summary>
bool TryGet<T>(string key, out T? value);
/// <summary>
/// 设置缓存
/// </summary>
/// <param name="key">缓存键</param>
/// <param name="value">缓存值</param>
/// <param name="expiration">绝对过期时间(从现在开始计算)</param>
void Set<T>(string key, T value, TimeSpan? expiration = null);
/// <summary>
/// 设置缓存(滑动过期)
/// </summary>
/// <param name="key">缓存键</param>
/// <param name="value">缓存值</param>
/// <param name="slidingExpiration">滑动过期时间(最后访问后N时间过期)</param>
void SetSliding<T>(string key, T value, TimeSpan slidingExpiration);
/// <summary>
/// 移除缓存
/// </summary>
void Remove(string key);
/// <summary>
/// 清空所有缓存
/// </summary>
void Clear();
/// <summary>
/// 检查缓存是否存在
/// </summary>
bool Exists(string key);
}
```
### 扩展方法(最常用)
```csharp
public static class CacheExtensions {
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(同步)
/// </summary>
public static T GetOrSet<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan? expiration = null);
/// <summary>
/// 获取缓存,不存在则执行工厂方法并缓存结果(异步)
/// </summary>
public static Task<T> GetOrSetAsync<T>(
this ICacheService cache,
string key,
Func<Task<T>> factory,
TimeSpan? expiration = null);
/// <summary>
/// 获取或设置(滑动过期)
/// </summary>
public static T GetOrSetSliding<T>(
this ICacheService cache,
string key,
Func<T> factory,
TimeSpan slidingExpiration);
/// <summary>
/// 批量移除(支持通配符)
/// </summary>
public static void RemoveByPattern(
this ICacheService cache,
string pattern); // 如 "user:*"
}
```
---
## 💡 典型使用场景
### 场景1:字典数据缓存(最常见)
```csharp
public class UserViewModel : ViewModelBase {
private readonly ICacheService _cache;
private readonly IRepository<Department> _deptRepo;
public async Task LoadDepartmentsAsync() {
// 部门数据很少变化,缓存1小时
Departments = await _cache.GetOrSetAsync(
"departments:all",
() => _deptRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
public void OnDepartmentUpdated() {
// 手动清除缓存,下次会重新加载
_cache.Remove("departments:all");
}
}
```
---
### 场景2:API响应缓存
```csharp
public class WeatherService {
private readonly ICacheService _cache;
private readonly IHttpClient _httpClient;
// 每个城市的天气数据缓存5分钟
public async Task<Weather> GetWeatherAsync(string city) {
return await _cache.GetOrSetAsync(
$"weather:{city}",
async () => {
var response = await _httpClient.GetAsync<WeatherApiResponse>(
$"https://api.weather.com/v1/current?city={city}");
return response.ToWeather();
},
TimeSpan.FromMinutes(5)
);
}
}
```
---
### 场景3:计算结果缓存
```csharp
public class ReportService {
private readonly ICacheService _cache;
// 月度报表计算耗时,缓存24小时
public async Task<MonthlyReport> GetMonthlyReportAsync(int year, int month) {
return await _cache.GetOrSetAsync(
$"report:monthly:{year}:{month}",
async () => {
// 复杂的统计计算
var data = await _repository.Query()
.Where(x => x.Date.Year == year && x.Date.Month == month)
.ToListAsync();
return CalculateReport(data); // 耗时操作
},
TimeSpan.FromHours(24)
);
}
// 当有新数据时,清除缓存
public void OnDataAdded(DateTime date) {
_cache.Remove($"report:monthly:{date.Year}:{date.Month}");
}
}
```
---
### 场景4:用户权限缓存
```csharp
public class AuthorizationService {
private readonly ICacheService _cache;
// 用户权限缓存(滑动过期:30分钟内无访问则过期)
public async Task<List<string>> GetUserPermissionsAsync(int userId) {
return await _cache.GetOrSetSliding(
$"user:permissions:{userId}",
async () => {
return await _repository.Query<UserPermission>()
.Where(x => x.UserId == userId)
.Select(x => x.PermissionCode)
.ToListAsync();
},
TimeSpan.FromMinutes(30) // 滑动过期
);
}
}
```
---
### 场景5:配置数据缓存
```csharp
public class SystemConfigService {
private readonly ICacheService _cache;
private readonly IConfigManager _configManager;
public T GetConfig<T>(string key) where T : class, new() {
return _cache.GetOrSet(
$"config:{key}",
() => _configManager.Get<T>(key),
TimeSpan.FromMinutes(10)
);
}
// Config模块有热重载,可以监听变化并清除缓存
public void OnConfigChanged(string key) {
_cache.Remove($"config:{key}");
}
}
```
---
## 🔧 高级功能
### 1. 缓存键约定
```csharp
// 推荐的缓存键命名规范
public static class CacheKeys {
// 格式: {模块}:{实体}:{参数}
public static string UserById(int userId) => $"user:profile:{userId}";
public static string AllDepartments => "departments:all";
public static string WeatherByCity(string city) => $"weather:{city}";
public static string ReportMonthly(int year, int month) => $"report:monthly:{year}:{month}";
}
// 使用
var user = await _cache.GetOrSetAsync(
CacheKeys.UserById(userId),
() => _userRepo.GetByIdAsync(userId),
TimeSpan.FromMinutes(10)
);
```
---
### 2. 缓存依赖(级联清除)
```csharp
public class ProductService {
// 当产品更新时,清除相关缓存
public async Task UpdateProductAsync(Product product) {
await _repository.UpdateAsync(product);
// 清除产品详情缓存
_cache.Remove($"product:{product.Id}");
// 清除产品列表缓存(可能包含此产品)
_cache.RemoveByPattern("products:list:*");
// 清除分类统计缓存
_cache.Remove($"category:stats:{product.CategoryId}");
}
}
```
---
### 3. 缓存预热
```csharp
public class CacheWarmupService {
private readonly ICacheService _cache;
// 应用启动时预热常用数据
public async Task WarmupAsync() {
// 加载所有字典数据
await _cache.GetOrSetAsync(
"departments:all",
() => _deptRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
await _cache.GetOrSetAsync(
"categories:all",
() => _categoryRepo.GetAllAsync(),
TimeSpan.FromHours(1)
);
// 加载系统配置
_cache.GetOrSet(
"system:config",
() => _configManager.Get<SystemConfig>(),
TimeSpan.FromMinutes(10)
);
}
}
// App.xaml.cs
protected override async void OnStartup(StartupEventArgs e) {
// ...
var warmup = _serviceProvider.GetRequiredService<CacheWarmupService>();
await warmup.WarmupAsync(); // 预热缓存
}
```
---
### 4. 缓存统计(可选)
```csharp
public interface ICacheStatistics {
long TotalHits { get; } // 命中次数
long TotalMisses { get; } // 未命中次数
double HitRate { get; } // 命中率
int CurrentItemCount { get; } // 当前缓存项数量
}
// 诊断用
public class DiagnosticsViewModel {
public void ShowCacheStats() {
var stats = _cache.GetStatistics();
MessageBox.Show($"缓存命中率: {stats.HitRate:P2}\n" +
$"缓存项数量: {stats.CurrentItemCount}");
}
}
```
---
## ⚙️ 配置选项
```csharp
services.AddMemoryCache(options => {
// 缓存大小限制(单位:MB)
options.SizeLimit = 100; // 限制100MB
// 压缩比例(当达到SizeLimit时,压缩到此比例)
options.CompactionPercentage = 0.25; // 压缩到25%
// 过期扫描频率
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
});
```
---
## 🏗️ 实现方案
### 基于 Microsoft.Extensions.Caching.Memory
- **内置库**:无需额外依赖
- **高性能**:基于 ConcurrentDictionary
- **线程安全**:完全线程安全
- **自动清理**:后台定期清理过期项
### 架构设计
```
ICacheService(接口)
↓
MemoryCacheService(实现)
↓ 内部使用
Microsoft.Extensions.Caching.Memory.IMemoryCache
```
---
## 📊 性能特点
- **读取速度**:< 1ms(内存访问)
- **线程安全**:无锁并发读取
- **内存占用**:可配置上限,自动压缩
- **过期清理**:后台自动扫描
---
## 🔄 过期策略对比
### 绝对过期 (Absolute Expiration)
```csharp
// 从设置时开始,10分钟后过期(无论是否访问)
_cache.Set("key", value, TimeSpan.FromMinutes(10));
// 适用场景:
// - API响应缓存(数据有时效性)
// - 报表数据(每天更新)
// - 字典数据(定期刷新)
```
### 滑动过期 (Sliding Expiration)
```csharp
// 最后访问后30分钟无访问则过期
_cache.SetSliding("key", value, TimeSpan.FromMinutes(30));
// 适用场景:
// - 用户会话数据
// - 用户权限缓存
// - 热点数据(频繁访问的数据)
```
### 组合使用
```csharp
// 滑动30分钟,但最多缓存2小时
services.AddMemoryCache();
_cache.Set("key", value, new MemoryCacheEntryOptions {
SlidingExpiration = TimeSpan.FromMinutes(30),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2)
});
```
---
## 💾 缓存大小管理
### 设置项大小
```csharp
_cache.Set("large-data", value, new MemoryCacheEntryOptions {
Size = 1, // 占用1个单位(Size单位由SizeLimit定义)
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
```
### 优先级
```csharp
// 低优先级:内存不足时优先清除
_cache.Set("low-priority", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.Low
});
// 高优先级:内存不足时最后清除
_cache.Set("high-priority", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.High
});
// 永不移除(谨慎使用)
_cache.Set("never-remove", value, new MemoryCacheEntryOptions {
Priority = CacheItemPriority.NeverRemove
});
```
---
## 📝 最佳实践
### 1. 缓存键命名规范
```csharp
✅ 好的命名
"user:profile:123"
"product:details:456"
"report:monthly:2024:12"
❌ 不好的命名
"user123"
"prod"
"report1"
```
### 2. 合理设置过期时间
```csharp
// 根据数据变化频率设置
字典数据:1小时 - 1天
用户信息:10分钟 - 30分钟
API响应:5分钟 - 15分钟
统计数据:1小时 - 24小时
```
### 3. 避免缓存过大对象
```csharp
❌ 不推荐:缓存整个DataTable
_cache.Set("all-products", largeDataTable);
✅ 推荐:缓存DTO列表
_cache.Set("all-products", productDtos);
```
### 4. 及时清除失效缓存
```csharp
public async Task UpdateUserAsync(User user) {
await _repository.UpdateAsync(user);
// 立即清除缓存
_cache.Remove($"user:profile:{user.Id}");
}
```
### 5. 使用Result<T>模式
```csharp
public async Task<Result<Product>> GetProductAsync(int id) {
return await _cache.GetOrSetAsync(
$"product:{id}",
async () => {
var product = await _repository.GetByIdAsync(id);
return product != null
? Result<Product>.Success(product)
: Result<Product>.Failure("产品不存在");
},
TimeSpan.FromMinutes(10)
);
}
```
---
## 🚀 未来扩展
### 可能的扩展方向
1. **分布式缓存**(基于 Redis)
2. **多级缓存**(L1: Memory, L2: Redis)
3. **缓存预加载**(后台任务)
4. **缓存同步**(多实例间同步)
---
## 📖 参考资料
- [Microsoft.Extensions.Caching.Memory](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory) - 官方文档
- [IMemoryCache](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.imemorycache) - API文档
- [缓存最佳实践](https://docs.microsoft.com/en-us/azure/architecture/best-practices/caching) - Azure架构指南
---
**状态**:设计阶段,未实现
**预计实现时间**:根据实际需求决定
**实现难度**:低(100行代码即可完成核心功能)
**依赖**:Microsoft.Extensions.Caching.Memory(.NET内置)
+1 -4
View File
@@ -1,12 +1,9 @@
<Application x:Class="Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- HandyControl 主题资源已通过 AddSharpHandyControl() 自动注册,无需手动添加 -->
<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>
</ResourceDictionary>
</Application.Resources>
</Application>
+93 -6
View File
@@ -5,12 +5,19 @@ using Client.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Common;
using Common.Configuration;
using Config;
using NetClient.DependencyInjection;
using Sharp.Infrastructure.Configuration;
using Sharp.Log.Extensions;
using Sharp.NetServer.DependencyInjection;
using Sharp.Wpf.Services;
using Sharp.Wpf.Abstractions;
using Sharp.Wpf.HandyControl.DependencyInjection;
using Sharp.Wpf.DevTools.DependencyInjection;
using Plc.Siemens.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Sharp.Process.DependencyInjection;
using Sharp.Python.DependencyInjection;
namespace Client
{
@@ -25,15 +32,67 @@ namespace Client
{
base.OnStartup(e);
// Global Exception Handling
DispatcherUnhandledException += (s, args) =>
{
// Safe logging: handle case where DI failed
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogError(args.Exception, "UI Thread Unhandled Exception");
}
catch { /* Ignore logging failure during crash */ }
}
MessageBox.Show($"发生未处理异常: {args.Exception.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
args.Handled = true; // Prevent crash
};
TaskScheduler.UnobservedTaskException += (s, args) =>
{
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogError(args.Exception, "Task Unobserved Exception");
}
catch { /* Ignore */ }
}
args.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (s, args) =>
{
var ex = args.ExceptionObject as Exception;
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogCritical(ex, "AppDomain Unhandled Exception (IsTerminating: {IsTerminating})", args.IsTerminating);
}
catch { /* Ignore */ }
}
if (args.IsTerminating)
{
MessageBox.Show($"发生严重错误,程序即将退出: {ex?.Message}", "严重错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
};
// 1) Build configuration manager from appsettings.json (+ reload on change + env vars)
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
// 2) Build DI container
var services = new ServiceCollection();
// ConfigManager and typed SystemConfig accessor
// ConfigManager and IConfiguration
services.AddSingleton(cm);
services.AddSystemConfig(cm);
// Register standard IConfiguration so that .BindConfiguration() works
services.AddSingleton<IConfiguration>(cm.Configuration);
// Logging (binds from section "SharpLog")
services.AddLogging(b => b.AddSharpLog());
@@ -42,30 +101,58 @@ namespace Client
services.AddNetClient(cm);
services.AddNetServer(cm);
// PLC
services.AddSiemensPlc(options =>
{
options.IpAddress = "127.0.0.1";
options.CpuType = S7.Net.CpuType.S71200;
// 配置监控循环频率 (单位: 毫秒)
// 默认 200ms。如果设为 100ms 则频率更高,但需注意网络负载。
options.MonitorInterval = 200;
});
// Process & Python
services.AddSharpProcess();
services.AddSharpPython();
// WPF app services
services.AddSingleton<IDialogService, DialogService>();
services.AddSharpHandyControl(); // HandyControl 对话框服务(也注册了 IDialogService)
services.AddSharpDevTools(); // 开发者工具(开发者工具、日志查看器、性能监控等)
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
services.AddSingleton<IUserSession, UserSession>();
// Register DispatcherService with the current UI dispatcher
services.AddSingleton<IDispatcher>(new DispatcherService(Dispatcher));
services.AddTransient<LoginViewModel>();
services.AddTransient<MainViewModel>();
services.AddTransient<PlcViewModel>();
services.AddTransient<ProcessTestViewModel>();
services.AddTransient<LoginWindow>();
services.AddTransient<MainWindow>();
services.AddTransient<PlcWindow>();
services.AddTransient<ProcessTestWindow>();
_serviceProvider = services.BuildServiceProvider();
// 3) Register global facades for convenience
Global.RegisterSingleton(_serviceProvider);
Global.RegisterSingleton<IServiceProvider>(_serviceProvider);
Global.RegisterSingleton(cm);
Global.RegisterSingleton(_serviceProvider.GetRequiredService<ISystemConfigAccessor>());
// Also register IDispatcher in Global if it's needed by static contexts (though injection is preferred)
Global.RegisterSingleton(_serviceProvider.GetRequiredService<IDispatcher>());
// 4) Show login window first, then main shell if authenticated
// Explicitly set ShutdownMode to prevent app from closing when LoginWindow closes
ShutdownMode = ShutdownMode.OnExplicitShutdown;
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
var loginResult = loginWindow.ShowDialog();
if (loginResult == true)
{
var main = _serviceProvider.GetRequiredService<MainWindow>();
Current.MainWindow = main;
ShutdownMode = ShutdownMode.OnMainWindowClose;
main.Show();
}
else
+8 -4
View File
@@ -9,18 +9,22 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="HandyControl" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wpf\Wpf.csproj" />
<ProjectReference Include="..\Wpf.HandyControl\Wpf.HandyControl.csproj" />
<ProjectReference Include="..\Wpf.DevTools\Wpf.DevTools.csproj" />
<ProjectReference Include="..\Config\Config.csproj" />
<ProjectReference Include="..\NetClient\NetClient.csproj" />
<ProjectReference Include="..\NetServer\NetServer.csproj" />
<ProjectReference Include="..\Log\Log.csproj" />
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
<ProjectReference Include="..\Ports\Ports.csproj" />
<ProjectReference Include="..\Process\Process.csproj" />
<ProjectReference Include="..\Python\Python.csproj" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel;
using Plc.Attributes;
namespace Client.Hardware;
// 必须继承 INotifyPropertyChanged 才能支持自动 UI 刷新
public interface IPackMachine : INotifyPropertyChanged
{
[PlcAddress("DB1.DBW0")]
short RunningSpeed { get; set; }
[PlcAddress("DB1.DBW2")]
short Temperature { get; set; }
// 支持 bool (比如 M 区)
[PlcAddress("M100.0")]
bool IsStartButtonPushed { get; set; }
}
+86 -1
View File
@@ -16,8 +16,39 @@
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</hc:GlowWindow.Resources>
<!-- 快捷键绑定 -->
<hc:GlowWindow.InputBindings>
<KeyBinding Key="F12" Command="{Binding OpenDevToolsCommand}" />
<KeyBinding Key="F11" Command="{Binding OpenLogViewerCommand}" />
<KeyBinding Key="F10" Command="{Binding OpenPerformanceMonitorCommand}" />
</hc:GlowWindow.InputBindings>
<Grid Background="{DynamicResource RegionBrush}">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- 菜单栏 -->
<Menu Grid.Row="0" Background="{DynamicResource RegionBrush}" Padding="8,4">
<MenuItem Header="文件(_F)">
<MenuItem Header="退出(_X)" InputGestureText="Alt+F4" Click="MenuItem_Exit_Click" />
</MenuItem>
<MenuItem Header="工具(_T)">
<MenuItem Header="PLC 调试(_P)" Command="{Binding OpenPlcToolCommand}" />
<MenuItem Header="串口调试(_S)" Command="{Binding OpenSerialPortToolCommand}" />
<MenuItem Header="Process/Python 测试(_T)" Command="{Binding OpenProcessTestCommand}" />
<Separator />
<MenuItem Header="开发者工具(_D)" Command="{Binding OpenDevToolsCommand}" InputGestureText="F12" />
<MenuItem Header="日志查看器(_L)" Command="{Binding OpenLogViewerCommand}" InputGestureText="F11" />
<MenuItem Header="性能监控(_M)" Command="{Binding OpenPerformanceMonitorCommand}" InputGestureText="F10" />
</MenuItem>
<MenuItem Header="帮助(_H)">
<MenuItem Header="关于(_A)" Command="{Binding OpenAboutCommand}" />
</MenuItem>
</Menu>
<Grid Grid.Row="1" Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -47,7 +78,22 @@
Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
Padding="18,6"
Margin="0,0,12,0"
Command="{Binding ThrowCommand}" />
<Button Content="Process/Python 测试"
Padding="18,6"
Margin="0,0,12,0"
Style="{StaticResource ButtonWarning}"
Command="{Binding OpenProcessTestCommand}" />
<Button Content="串口调试"
Padding="18,6"
Margin="0,0,12,0"
Style="{StaticResource ButtonInfo}"
Command="{Binding OpenSerialPortToolCommand}" />
<Button Content="PLC 调试"
Padding="18,6"
Style="{StaticResource ButtonPrimary}"
Command="{Binding OpenPlcToolCommand}" />
</StackPanel>
</Grid>
</Border>
@@ -82,6 +128,45 @@
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="包装机状态 (PLC)" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="速度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
<TextBlock Text="{Binding Machine.RunningSpeed}" FontWeight="Bold" />
<TextBlock Text=" rpm" Foreground="{DynamicResource SecondaryTextBrush}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="温度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
<TextBlock Text="{Binding Machine.Temperature}" FontWeight="Bold" />
<TextBlock Text=" ℃" Foreground="{DynamicResource SecondaryTextBrush}"/>
</StackPanel>
<!-- 交互控制演示 -->
<Separator Margin="0,10" Background="{DynamicResource BorderBrush}"/>
<TextBlock Text="控制面板" FontWeight="SemiBold" Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="设定速度:" VerticalAlignment="Center"/>
<Slider Grid.Column="1" Minimum="0" Maximum="2000" Value="{Binding Machine.RunningSpeed}" Margin="10,0" IsMoveToPointEnabled="True"/>
<TextBlock Grid.Column="2" Text="{Binding Machine.RunningSpeed}" Width="40" TextAlignment="Right"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="启动机器" Command="{Binding StartMachineCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonSuccess}"/>
<Button Content="停止机器" Command="{Binding StopMachineCommand}" Style="{StaticResource ButtonDanger}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox IsChecked="{Binding Machine.IsStartButtonPushed}" Content="启动按钮状态 (双向绑定)" IsHitTestVisible="False"/>
</StackPanel>
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="提示" FontWeight="SemiBold" />
+6
View File
@@ -1,3 +1,4 @@
using System.Windows;
using HandyControl.Controls;
using Client.ViewModels;
@@ -10,4 +11,9 @@ public partial class MainWindow : GlowWindow
InitializeComponent();
DataContext = viewModel;
}
private void MenuItem_Exit_Click(object sender, RoutedEventArgs e)
{
Application.Current.Shutdown();
}
}
+91
View File
@@ -0,0 +1,91 @@
<hc:GlowWindow x:Class="Client.PlcWindow"
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"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
mc:Ignorable="d"
Title="PLC 调试工具" Height="500" Width="800"
WindowStartupLocation="CenterOwner">
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</hc:GlowWindow.Resources>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 连接控制 -->
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="品牌:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox ItemsSource="{Binding Brands}" SelectedItem="{Binding SelectedBrand}" Width="120" Margin="0,0,15,0"/>
<StackPanel Orientation="Horizontal" Visibility="{Binding IsSiemensOptionsVisible, Converter={StaticResource Bool2Vis}}">
<TextBlock Text="型号:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox ItemsSource="{Binding CpuTypes}" SelectedItem="{Binding SelectedCpuType}" Width="120" Margin="0,0,15,0"/>
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBox hc:InfoElement.Placeholder="IP 地址" hc:InfoElement.Title="IP" Text="{Binding IpAddress}" Width="200" Margin="0,0,10,0"/>
<Button Content="连接 / 启动服务" Command="{Binding ConnectCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonPrimary}"/>
<Button Content="断开" Command="{Binding DisconnectCommand}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold" Foreground="{DynamicResource PrimaryTextBrush}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 单次读写 -->
<GroupBox Header="单次读写测试 (默认 Short)" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="读取地址:" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding ReadAddress}" Margin="5,0"/>
<Button Grid.Column="2" Content="读取" Command="{Binding ReadCommand}" Margin="5,0"/>
<TextBlock Grid.Column="3" Text="{Binding ReadValue}" VerticalAlignment="Center" Margin="5,0" FontWeight="Bold"/>
<TextBlock Grid.Row="1" Text="写入地址:" VerticalAlignment="Center" Margin="0,10,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding WriteAddress}" Margin="5,10,0,0"/>
<TextBox Grid.Row="1" Grid.Column="3" Text="{Binding WriteValue}" Margin="5,10,0,0"/>
<Button Grid.Row="1" Grid.Column="4" Content="写入" Command="{Binding WriteCommand}" Margin="5,10,0,0"/>
</Grid>
</GroupBox>
<!-- 监控 -->
<GroupBox Header="实时监控 (Add Watch)" Grid.Row="2" Margin="0,0,0,15">
<StackPanel Orientation="Horizontal" Margin="10">
<TextBox hc:InfoElement.Placeholder="地址 (如 DB1.DBW0)" Text="{Binding MonitorAddress}" Width="200" Margin="0,0,10,0"/>
<ComboBox ItemsSource="{Binding SupportedMonitorTypes}" SelectedValue="{Binding SelectedMonitorType}" SelectedValuePath="Type" DisplayMemberPath="Name" Width="120" Margin="0,0,10,0"/>
<Button Content="添加监控" Command="{Binding AddMonitorCommand}"/>
</StackPanel>
</GroupBox>
<!-- 监控列表 -->
<DataGrid Grid.Row="3" ItemsSource="{Binding MonitoredTags}" AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="100"/>
<DataGridTextColumn Header="地址" Binding="{Binding Address}" Width="150"/>
<DataGridTextColumn Header="类型" Binding="{Binding DataTypeName}" Width="80"/>
<DataGridTextColumn Header="当前值" Binding="{Binding Value}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</hc:GlowWindow>
+14
View File
@@ -0,0 +1,14 @@
using System.Windows;
using Client.ViewModels;
using HandyControl.Controls;
namespace Client;
public partial class PlcWindow : GlowWindow
{
public PlcWindow(PlcViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
+201
View File
@@ -0,0 +1,201 @@
<hc:GlowWindow x:Class="Client.ProcessTestWindow"
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"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
mc:Ignorable="d"
Title="Process / Python 测试工具" Height="700" Width="1000"
WindowStartupLocation="CenterOwner">
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</hc:GlowWindow.Resources>
<Grid Margin="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="300"/>
</Grid.ColumnDefinitions>
<!-- 主测试区域 -->
<TabControl Grid.Column="0" Margin="0,0,10,0">
<!-- Process 测试标签页 -->
<TabItem Header="Process 测试">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="15">
<!-- 状态栏 -->
<Border Background="{DynamicResource PrimaryBrush}" Padding="10" CornerRadius="4" Margin="0,0,0,15">
<TextBlock Text="{Binding ProcessStatus}" Foreground="White" FontWeight="Bold"/>
</Border>
<!-- 命令输入 -->
<GroupBox Header="命令配置" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="命令:" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding Command}" Margin="10,0,0,0"/>
<TextBlock Grid.Row="1" Text="参数:" VerticalAlignment="Center" Margin="0,10,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Arguments}" Margin="10,10,0,0"/>
<TextBlock Grid.Row="2" Text="超时(秒):" VerticalAlignment="Center" Margin="0,10,0,0"/>
<hc:NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding TimeoutSeconds}"
Minimum="1" Maximum="600" Margin="10,10,0,0"/>
</Grid>
</GroupBox>
<!-- 快捷按钮 -->
<GroupBox Header="快速测试" Margin="0,0,0,15">
<WrapPanel Margin="10" Orientation="Horizontal">
<Button Content="执行命令" Command="{Binding RunProcessCommand}"
Margin="0,0,10,10" Padding="15,8" Style="{StaticResource ButtonPrimary}"/>
<Button Content="Shell 命令" Command="{Binding RunShellCommand}"
Margin="0,0,10,10" Padding="15,8"/>
<Button Content="测试 dotnet" Command="{Binding TestDotnetCommand}"
Margin="0,0,10,10" Padding="15,8" Style="{StaticResource ButtonInfo}"/>
<Button Content="测试 Git" Command="{Binding TestGitCommand}"
Margin="0,0,10,10" Padding="15,8" Style="{StaticResource ButtonInfo}"/>
</WrapPanel>
</GroupBox>
<!-- 输出 -->
<GroupBox Header="执行输出">
<TextBox Text="{Binding ProcessOutput, Mode=OneWay}"
Height="300"
IsReadOnly="True"
VerticalScrollBarVisibility="Auto"
FontFamily="Consolas"
Margin="10"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Python 测试标签页 -->
<TabItem Header="Python 测试">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="15">
<!-- Python 环境信息 -->
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Python 环境" FontSize="16" FontWeight="Bold"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<TextBlock Text="路径: " FontWeight="SemiBold"/>
<TextBlock Text="{Binding PythonPath}" Margin="5,0,0,0"/>
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,5,0,0">
<TextBlock Text="版本: " FontWeight="SemiBold"/>
<TextBlock Text="{Binding PythonVersion}" Margin="5,0,20,0"/>
<Button Content="重新检测" Command="{Binding DetectPythonCommand}" Padding="10,4"/>
</StackPanel>
</Grid>
</Border>
<!-- 状态栏 -->
<Border Background="{DynamicResource SuccessBrush}" Padding="10" CornerRadius="4" Margin="0,0,0,15">
<TextBlock Text="{Binding PythonStatus}" Foreground="White" FontWeight="Bold"/>
</Border>
<!-- 代码执行 -->
<GroupBox Header="Python 代码执行" Margin="0,0,0,15">
<StackPanel Margin="10">
<TextBlock Text="Python 代码:" Margin="0,0,0,5"/>
<TextBox Text="{Binding PythonCode}"
Height="80"
TextWrapping="Wrap"
AcceptsReturn="True"
FontFamily="Consolas"
Margin="0,0,0,10"/>
<WrapPanel>
<Button Content="执行代码" Command="{Binding RunPythonCodeCommand}"
Margin="0,0,10,0" Padding="15,8" Style="{StaticResource ButtonSuccess}"/>
<Button Content="测试 JSON 输出" Command="{Binding TestPythonJsonCommand}"
Padding="15,8" Style="{StaticResource ButtonInfo}"/>
</WrapPanel>
</StackPanel>
</GroupBox>
<!-- 脚本执行 -->
<GroupBox Header="Python 脚本执行">
<StackPanel Margin="10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding PythonScriptPath}"
hc:InfoElement.Placeholder="脚本文件路径"/>
<Button Grid.Column="1" Content="创建测试脚本"
Command="{Binding CreateTestScriptCommand}"
Margin="5,0,0,0" Padding="10,4"/>
</Grid>
<TextBlock Text="参数 (格式: key1=value1,key2=value2):" Margin="0,10,0,5"/>
<TextBox Text="{Binding PythonArgs}"
hc:InfoElement.Placeholder="例如: name=Sharp,count=3"
Margin="0,0,0,10"/>
<Button Content="执行脚本" Command="{Binding RunPythonScriptCommand}"
Padding="15,8" Style="{StaticResource ButtonPrimary}"/>
</StackPanel>
</GroupBox>
<!-- Python 输出 -->
<GroupBox Header="Python 输出" Margin="0,15,0,0">
<TextBox Text="{Binding PythonOutput, Mode=OneWay}"
Height="200"
IsReadOnly="True"
VerticalScrollBarVisibility="Auto"
FontFamily="Consolas"
Margin="10"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
<!-- 日志面板 -->
<Border Grid.Column="1" Background="{DynamicResource RegionBrush}"
Padding="15" CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="执行日志" FontSize="16" FontWeight="Bold" Margin="0,0,0,10"/>
<ListBox Grid.Row="1" ItemsSource="{Binding LogMessages}"
FontFamily="Consolas" FontSize="10"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="2"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<Button Grid.Row="2" Content="清空日志" Command="{Binding ClearLogCommand}"
Margin="0,10,0,0" HorizontalAlignment="Stretch"/>
</Grid>
</Border>
</Grid>
</hc:GlowWindow>
@@ -0,0 +1,15 @@
using Client.ViewModels;
namespace Client;
/// <summary>
/// ProcessTestWindow.xaml 的交互逻辑
/// </summary>
public partial class ProcessTestWindow
{
public ProcessTestWindow(ProcessTestViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
+44
View File
@@ -0,0 +1,44 @@
# Sharp.Client
基于 WPF 的工业客户端应用程序示例,展示了如何集成套件中的各个模块(PLC、串口、配置、日志、MVVM)。
## 功能概览
- **用户登录**:基于 `Sharp.Data` 的用户验证。
- **PLC 监控**:展示 `Plc.Siemens` 模块的使用,实时读取/写入 PLC 数据。
- **串口调试**:集成 `Ports` 模块,提供串口发现、连接与数据收发界面。
- **MVVM 架构**:使用 `Sharp.Wpf` 提供的 ViewModelBase、Command 和 DialogService。
## 关键集成点
### 1. 启动初始化 (App.xaml.cs)
展示了如何按顺序初始化基础设施:
- 加载配置 (`Sharp.Config`)
- 初始化日志 (`Sharp.Log`)
- 配置数据库 (`Sharp.Data`)
- 注册服务到 DI 容器 (`Sharp.Common.Global`)
### 2. PLC 交互 (PlcWindow)
- 演示了 `IPlcService` 的使用。
- 订阅模式(Subscribe) vs 即时读取(ReadAsync)。
- UI 绑定与数据转换。
### 3. 串口通信 (SerialPortWindow)
- 串口列表刷新与选择。
- 16进制与 ASCII 模式切换。
- 数据收发日志。
## 开发指南
1. 确保已安装 .NET 8 SDK。
2. 运行项目:
```bash
dotnet run --project Client.csproj
```
3. 默认登录账号(如使用示例数据库):`admin` / `123456`(需自行在初始化时播种)。
## 目录结构
- `Hardware/`: 硬件抽象层(如扫码枪、打印机接口)。
- `Services/`: 客户端特定服务(如 `LoginService`)。
- `ViewModels/`: MVVM 视图模型。
- `Views/`: XAML 窗口与控件。
+301
View File
@@ -0,0 +1,301 @@
<hc:GlowWindow x:Class="Client.SerialPortWindow"
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="串口通信调试工具" Height="750" Width="1200"
WindowStartupLocation="CenterOwner">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TabControl Grid.Row="0" Grid.RowSpan="2" Style="{StaticResource TabControlCapsule}">
<!-- Tab 1: 串口发现 -->
<TabItem Header="串口发现">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel Orientation="Horizontal">
<Button Content="刷新串口列表" Click="RefreshPorts_Click" Style="{StaticResource ButtonPrimary}" Margin="0,0,10,0"/>
<Button Content="仅显示USB串口" Click="ShowUsbPorts_Click" Margin="0,0,10,0"/>
<Button Content="测试连接" Click="TestConnection_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="PortCountText" Text="发现 0 个串口" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</Border>
<DataGrid x:Name="PortsDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" HeadersVisibility="Column" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="端口" Binding="{Binding PortName}" Width="100"/>
<DataGridTextColumn Header="描述" Binding="{Binding Description}" Width="200"/>
<DataGridTextColumn Header="制造商" Binding="{Binding Manufacturer}" Width="150"/>
<DataGridCheckBoxColumn Header="USB设备" Binding="{Binding IsUsbSerial}" Width="80"/>
<DataGridTextColumn Header="VID" Binding="{Binding VendorId}" Width="80"/>
<DataGridTextColumn Header="PID" Binding="{Binding ProductId}" Width="80"/>
<DataGridCheckBoxColumn Header="被占用" Binding="{Binding IsInUse}" Width="80"/>
<DataGridTextColumn Header="设备ID" Binding="{Binding DeviceId}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock Grid.Row="2" x:Name="DiscoveryStatusText" Text="点击'刷新串口列表'开始"
Foreground="{DynamicResource SecondaryTextBrush}" Margin="0,10,0,0"/>
</Grid>
</TabItem>
<!-- Tab 2: 基础通信 -->
<TabItem Header="基础通信">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 连接配置 -->
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="PortNameCombo" Width="100" Margin="0,0,15,0"/>
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="BaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="4800"/>
<ComboBoxItem Content="9600"/>
<ComboBoxItem Content="19200"/>
<ComboBoxItem Content="38400"/>
<ComboBoxItem Content="57600"/>
<ComboBoxItem Content="115200"/>
</ComboBox>
<TextBlock Text="数据位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="DataBitsCombo" Width="60" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="5"/>
<ComboBoxItem Content="6"/>
<ComboBoxItem Content="7"/>
<ComboBoxItem Content="8"/>
</ComboBox>
<TextBlock Text="校验位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ParityCombo" Width="80" Margin="0,0,15,0" SelectedIndex="0">
<ComboBoxItem Content="None"/>
<ComboBoxItem Content="Odd"/>
<ComboBoxItem Content="Even"/>
<ComboBoxItem Content="Mark"/>
<ComboBoxItem Content="Space"/>
</ComboBox>
<TextBlock Text="停止位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="StopBitsCombo" Width="80" Margin="0,0,0,0" SelectedIndex="1">
<ComboBoxItem Content="None"/>
<ComboBoxItem Content="One"/>
<ComboBoxItem Content="Two"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<Button x:Name="OpenButton" Content="打开串口" Click="OpenPort_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
<Button x:Name="CloseButton" Content="关闭串口" Click="ClosePort_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
<TextBlock x:Name="ConnectionStatusText" Text="未连接" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</Grid>
</Border>
<!-- 发送区域 -->
<GroupBox Header="发送数据" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="SendDataTextBox" Width="400" hc:InfoElement.Placeholder="输入要发送的数据 (十六进制,如: 01 02 03)" Margin="0,0,10,0"/>
<Button Content="发送 (HEX)" Click="SendHex_Click" Margin="0,0,10,0"/>
<Button Content="发送 (文本)" Click="SendText_Click" Margin="0,0,10,0"/>
<Button Content="清空" Click="ClearSend_Click"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="AutoAddNewLineCheckBox" Content="自动添加换行符" IsChecked="True" Margin="0,0,15,0"/>
<TextBlock Text="编码:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="EncodingCombo" Width="100" SelectedIndex="0">
<ComboBoxItem Content="UTF-8"/>
<ComboBoxItem Content="ASCII"/>
<ComboBoxItem Content="GBK"/>
</ComboBox>
</StackPanel>
</Grid>
</GroupBox>
<!-- 接收区域 -->
<GroupBox Header="接收数据" Grid.Row="2" Margin="0,0,0,10">
<StackPanel Orientation="Horizontal" Margin="10">
<CheckBox x:Name="HexDisplayCheckBox" Content="HEX显示" IsChecked="True" Margin="0,0,15,0"/>
<CheckBox x:Name="AutoScrollCheckBox" Content="自动滚动" IsChecked="True" Margin="0,0,15,0"/>
<Button Content="清空接收区" Click="ClearReceive_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="ReceivedBytesText" Text="已接收: 0 字节" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</GroupBox>
<TextBox x:Name="ReceiveTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
</Grid>
</TabItem>
<!-- Tab 3: 高级监听 -->
<TabItem Header="高级监听">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ServicePortCombo" Width="100" Margin="0,0,15,0"/>
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ServiceBaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="9600"/>
<ComboBoxItem Content="19200"/>
<ComboBoxItem Content="38400"/>
<ComboBoxItem Content="57600"/>
<ComboBoxItem Content="115200"/>
</ComboBox>
<CheckBox x:Name="AutoReconnectCheckBox" Content="自动重连" IsChecked="True" Margin="0,0,15,0" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="StartListeningButton" Content="启动监听" Click="StartListening_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
<Button x:Name="StopListeningButton" Content="停止监听" Click="StopListening_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
<TextBlock x:Name="ServiceStatusText" Text="未启动" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</StackPanel>
</Border>
<DataGrid x:Name="EventLogDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" CanUserResizeColumns="True" Margin="0,0,0,15">
<DataGrid.Columns>
<DataGridTextColumn Header="时间" Binding="{Binding Time, StringFormat=HH:mm:ss.fff}" Width="100"/>
<DataGridTextColumn Header="类型" Binding="{Binding EventType}" Width="100"/>
<DataGridTextColumn Header="数据/消息" Binding="{Binding Data}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel Grid.Row="2" Orientation="Horizontal">
<Button Content="清空日志" Click="ClearEventLog_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="EventCountText" Text="事件数: 0" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</Grid>
</TabItem>
<!-- Tab 4: 数据工具 -->
<TabItem Header="数据工具">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 数据转换 -->
<GroupBox Header="数据转换" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="ConvertInputTextBox" Width="300" hc:InfoElement.Placeholder="输入数据" Margin="0,0,10,0"/>
<ComboBox x:Name="ConvertTypeCombo" Width="120" Margin="0,0,10,0" SelectedIndex="0">
<ComboBoxItem Content="HEX -> 字节"/>
<ComboBoxItem Content="字节 -> HEX"/>
<ComboBoxItem Content="字节 -> Int16"/>
<ComboBoxItem Content="字节 -> Int32"/>
<ComboBoxItem Content="字节 -> Float"/>
<ComboBoxItem Content="Int32 -> 字节"/>
</ComboBox>
<CheckBox x:Name="BigEndianCheckBox" Content="大端序" Margin="0,0,10,0" VerticalAlignment="Center"/>
<Button Content="转换" Click="ConvertData_Click"/>
</StackPanel>
<TextBlock Grid.Row="1" Text="转换结果:" Margin="0,10,0,5"/>
<TextBox Grid.Row="2" x:Name="ConvertResultTextBox" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
</Grid>
</GroupBox>
<!-- 校验计算 -->
<GroupBox Header="校验计算" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="ChecksumInputTextBox" Width="400" hc:InfoElement.Placeholder="输入十六进制数据 (如: 01 02 03)" Margin="0,0,10,0"/>
<Button Content="CRC16" Click="CalculateCrc16_Click" Margin="0,0,5,0"/>
<Button Content="LRC" Click="CalculateLrc_Click" Margin="0,0,5,0"/>
<Button Content="XOR" Click="CalculateXor_Click" Margin="0,0,5,0"/>
<Button Content="累加和" Click="CalculateChecksum_Click"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<TextBlock Text="校验结果:" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBox x:Name="ChecksumResultTextBox" Width="200" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
</StackPanel>
</Grid>
</GroupBox>
<!-- 诊断工具 -->
<GroupBox Header="诊断工具" Grid.Row="2" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="DiagPortCombo" Width="100" Margin="0,0,10,0"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button Content="测试连接" Click="DiagTestConnection_Click" Margin="0,0,5,0"/>
<Button Content="波特率扫描" Click="ScanBaudRates_Click" Margin="0,0,5,0"/>
<Button Content="流量监控(10s)" Click="MonitorTraffic_Click"/>
</StackPanel>
</Grid>
</GroupBox>
<!-- 诊断结果 -->
<TextBox x:Name="DiagResultTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
</Grid>
</TabItem>
</TabControl>
</Grid>
</hc:GlowWindow>
+644
View File
@@ -0,0 +1,644 @@
using System.Collections.ObjectModel;
using System.IO.Ports;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using HandyControl.Controls;
using Ports.Abstractions;
using Ports.Discovery;
using Ports.Drivers;
using Ports.Models;
using Ports.Services;
using Ports.Utils;
namespace Client;
public partial class SerialPortWindow
{
private ISerialPortDriver? _driver;
private ISerialPortService? _service;
private int _totalReceivedBytes;
private readonly ObservableCollection<EventLogItem> _eventLog = new();
public SerialPortWindow()
{
InitializeComponent();
InitializeComboBoxes();
EventLogDataGrid.ItemsSource = _eventLog;
}
private void InitializeComboBoxes()
{
// 初始化波特率下拉框
BaudRateCombo.SelectedItem = BaudRateCombo.Items[4]; // 115200
// 加载串口列表
RefreshPortsList();
}
private void RefreshPortsList()
{
var result = SerialPortDiscovery.GetAvailablePorts();
if (result.IsSuccess && result.Value != null)
{
var portNames = result.Value.Select(p => p.PortName).ToList();
PortNameCombo.ItemsSource = portNames;
ServicePortCombo.ItemsSource = portNames;
DiagPortCombo.ItemsSource = portNames;
if (portNames.Count > 0)
{
PortNameCombo.SelectedIndex = 0;
ServicePortCombo.SelectedIndex = 0;
DiagPortCombo.SelectedIndex = 0;
}
}
}
#region Tab 1: 串口发现
private void RefreshPorts_Click(object sender, RoutedEventArgs e)
{
var result = SerialPortDiscovery.GetAvailablePorts();
if (result.IsSuccess && result.Value != null)
{
PortsDataGrid.ItemsSource = result.Value;
PortCountText.Text = $"发现 {result.Value.Count} 个串口";
DiscoveryStatusText.Text = $"最后刷新: {DateTime.Now:HH:mm:ss}";
RefreshPortsList();
}
else
{
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
}
}
private void ShowUsbPorts_Click(object sender, RoutedEventArgs e)
{
var result = SerialPortDiscovery.GetUsbSerialPorts();
if (result.IsSuccess && result.Value != null)
{
PortsDataGrid.ItemsSource = result.Value;
PortCountText.Text = $"发现 {result.Value.Count} 个USB串口";
DiscoveryStatusText.Text = $"仅显示USB设备";
}
else
{
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
}
}
private async void TestConnection_Click(object sender, RoutedEventArgs e)
{
var selected = PortsDataGrid.SelectedItem as SerialPortInfo;
if (selected == null)
{
HandyControl.Controls.MessageBox.Show("请先选择一个串口", "提示");
return;
}
DiscoveryStatusText.Text = $"正在测试 {selected.PortName}...";
var options = new SerialPortOptions { PortName = selected.PortName };
var result = await SerialPortDiagnostics.TestConnectionAsync(selected.PortName, options);
if (result.IsSuccess)
{
DiscoveryStatusText.Text = "测试完成";
HandyControl.Controls.MessageBox.Show(result.Value, "测试结果");
}
else
{
DiscoveryStatusText.Text = $"测试失败: {result.ErrorMessage}";
}
}
#endregion
#region Tab 2: 基础通信
private async void OpenPort_Click(object sender, RoutedEventArgs e)
{
if (PortNameCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
var options = new SerialPortOptions
{
PortName = PortNameCombo.SelectedItem.ToString()!,
BaudRate = int.Parse(((ComboBoxItem)BaudRateCombo.SelectedItem).Content.ToString()!),
DataBits = int.Parse(((ComboBoxItem)DataBitsCombo.SelectedItem).Content.ToString()!),
Parity = Enum.Parse<Parity>(((ComboBoxItem)ParityCombo.SelectedItem).Content.ToString()!),
StopBits = Enum.Parse<StopBits>(((ComboBoxItem)StopBitsCombo.SelectedItem).Content.ToString()!)
};
_driver = new SerialPortDriver();
var result = await _driver.OpenAsync(options);
if (result.IsSuccess)
{
ConnectionStatusText.Text = "已连接";
OpenButton.IsEnabled = false;
CloseButton.IsEnabled = true;
_totalReceivedBytes = 0;
// 启动接收线程
_ = Task.Run(ReceiveLoop);
}
else
{
ConnectionStatusText.Text = $"连接失败: {result.ErrorMessage}";
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "连接失败", "错误");
}
}
private async void ClosePort_Click(object sender, RoutedEventArgs e)
{
if (_driver != null)
{
await _driver.CloseAsync();
_driver.Dispose();
_driver = null;
}
ConnectionStatusText.Text = "未连接";
OpenButton.IsEnabled = true;
CloseButton.IsEnabled = false;
}
private async void SendHex_Click(object sender, RoutedEventArgs e)
{
if (_driver == null || !_driver.IsOpen)
{
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
return;
}
var hexResult = SerialDataConverter.HexStringToBytes(SendDataTextBox.Text);
if (hexResult.IsFailure)
{
HandyControl.Controls.MessageBox.Show(hexResult.ErrorMessage ?? "数据格式错误", "错误");
return;
}
var result = await _driver.WriteBytesAsync(hexResult.Value!);
if (result.IsFailure)
{
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "发送失败", "错误");
}
}
private async void SendText_Click(object sender, RoutedEventArgs e)
{
if (_driver == null || !_driver.IsOpen)
{
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
return;
}
var encoding = GetSelectedEncoding();
var text = SendDataTextBox.Text;
if (AutoAddNewLineCheckBox.IsChecked == true)
text += "\r\n";
var bytesResult = SerialDataConverter.StringToBytes(text, encoding);
if (bytesResult.IsSuccess && bytesResult.Value != null)
{
await _driver.WriteBytesAsync(bytesResult.Value);
}
}
private void ClearSend_Click(object sender, RoutedEventArgs e)
{
SendDataTextBox.Clear();
}
private void ClearReceive_Click(object sender, RoutedEventArgs e)
{
ReceiveTextBox.Clear();
_totalReceivedBytes = 0;
ReceivedBytesText.Text = "已接收: 0 字节";
}
private async Task ReceiveLoop()
{
while (_driver != null && _driver.IsOpen)
{
try
{
if (_driver.BytesToRead > 0)
{
var result = await _driver.ReadAvailableAsync();
if (result.IsSuccess && result.Value != null && result.Value.Length > 0)
{
_totalReceivedBytes += result.Value.Length;
Dispatcher.Invoke(() => DisplayReceivedData(result.Value));
}
}
await Task.Delay(10);
}
catch
{
break;
}
}
}
private void DisplayReceivedData(byte[] data)
{
string display;
if (HexDisplayCheckBox.IsChecked == true)
{
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
display = hexResult.Value + "\r\n";
}
else
{
var encoding = GetSelectedEncoding();
var strResult = SerialDataConverter.BytesToString(data, encoding);
display = strResult.Value ?? string.Empty;
}
ReceiveTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {display}");
ReceivedBytesText.Text = $"已接收: {_totalReceivedBytes} 字节";
if (AutoScrollCheckBox.IsChecked == true)
{
ReceiveTextBox.ScrollToEnd();
}
}
#endregion
#region Tab 3: 高级监听
private async void StartListening_Click(object sender, RoutedEventArgs e)
{
if (ServicePortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
var options = new SerialPortOptions
{
PortName = ServicePortCombo.SelectedItem.ToString()!,
BaudRate = int.Parse(((ComboBoxItem)ServiceBaudRateCombo.SelectedItem).Content.ToString()!)
};
_service = new SerialPortService(new SerialPortDriver());
// 订阅事件
_service.DataReceived += OnServiceDataReceived;
_service.ErrorOccurred += OnServiceErrorOccurred;
_service.ConnectionStateChanged += OnServiceConnectionStateChanged;
var autoReconnect = AutoReconnectCheckBox.IsChecked == true;
var result = await _service.StartListeningAsync(options, autoReconnect);
if (result.IsSuccess)
{
ServiceStatusText.Text = "监听中";
StartListeningButton.IsEnabled = false;
StopListeningButton.IsEnabled = true;
}
else
{
ServiceStatusText.Text = $"启动失败";
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "启动失败", "错误");
}
}
private async void StopListening_Click(object sender, RoutedEventArgs e)
{
if (_service != null)
{
try
{
await _service.StopListeningAsync();
}
catch (OperationCanceledException)
{
// 任务取消是正常情况,忽略
}
catch (Exception ex)
{
HandyControl.Controls.MessageBox.Show($"停止监听时发生错误: {ex.Message}", "错误");
}
try
{
_service.Dispose();
}
catch
{
// 忽略清理异常
}
_service = null;
}
ServiceStatusText.Text = "未启动";
StartListeningButton.IsEnabled = true;
StopListeningButton.IsEnabled = false;
}
private void OnServiceDataReceived(object? sender, DataReceivedEventArgs e)
{
Dispatcher.Invoke(() =>
{
var hexResult = SerialDataConverter.BytesToHexString(e.Data, " ");
_eventLog.Add(new EventLogItem
{
Time = e.ReceivedAt,
EventType = "数据接收",
Data = $"{e.Data.Length} 字节: {hexResult.Value}"
});
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void OnServiceErrorOccurred(object? sender, Ports.Models.ErrorEventArgs e)
{
Dispatcher.Invoke(() =>
{
_eventLog.Add(new EventLogItem
{
Time = e.OccurredAt,
EventType = "错误",
Data = e.ErrorMessage
});
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void OnServiceConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Dispatcher.Invoke(() =>
{
_eventLog.Add(new EventLogItem
{
Time = e.ChangedAt,
EventType = "状态变化",
Data = $"{e.PreviousState} -> {e.CurrentState}"
});
ServiceStatusText.Text = e.CurrentState.ToString();
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void ClearEventLog_Click(object sender, RoutedEventArgs e)
{
_eventLog.Clear();
EventCountText.Text = "事件数: 0";
}
#endregion
#region Tab 4: 数据工具
private void ConvertData_Click(object sender, RoutedEventArgs e)
{
var input = ConvertInputTextBox.Text;
var type = ((ComboBoxItem)ConvertTypeCombo.SelectedItem).Content.ToString();
var isBigEndian = BigEndianCheckBox.IsChecked == true;
try
{
switch (type)
{
case "HEX -> 字节":
var hexBytes = SerialDataConverter.HexStringToBytes(input);
ConvertResultTextBox.Text = hexBytes.IsSuccess ? $"字节数组长度: {hexBytes.Value!.Length}" : hexBytes.ErrorMessage;
break;
case "字节 -> HEX":
var bytes = SerialDataConverter.HexStringToBytes(input);
if (bytes.IsSuccess)
{
var hex = SerialDataConverter.BytesToHexString(bytes.Value!, " ");
ConvertResultTextBox.Text = hex.Value;
}
break;
case "字节 -> Int16":
var int16Bytes = SerialDataConverter.HexStringToBytes(input);
if (int16Bytes.IsSuccess)
{
var int16 = SerialDataConverter.BytesToInt16(int16Bytes.Value!, isBigEndian);
ConvertResultTextBox.Text = int16.IsSuccess ? int16.Value.ToString() : int16.ErrorMessage;
}
break;
case "字节 -> Int32":
var int32Bytes = SerialDataConverter.HexStringToBytes(input);
if (int32Bytes.IsSuccess)
{
var int32 = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian);
ConvertResultTextBox.Text = int32.IsSuccess ? int32.Value.ToString() : int32.ErrorMessage;
}
break;
case "字节 -> Float":
var floatBytes = SerialDataConverter.HexStringToBytes(input);
if (floatBytes.IsSuccess)
{
var floatValue = SerialDataConverter.BytesToFloat(floatBytes.Value!, isBigEndian);
ConvertResultTextBox.Text = floatValue.IsSuccess ? floatValue.Value.ToString("F4") : floatValue.ErrorMessage;
}
break;
case "Int32 -> 字节":
if (int.TryParse(input, out var intValue))
{
var intBytes = SerialDataConverter.Int32ToBytes(intValue, isBigEndian);
if (intBytes.IsSuccess)
{
var hex = SerialDataConverter.BytesToHexString(intBytes.Value!, " ");
ConvertResultTextBox.Text = hex.Value;
}
}
else
{
ConvertResultTextBox.Text = "输入不是有效的整数";
}
break;
}
}
catch (Exception ex)
{
ConvertResultTextBox.Text = $"转换错误: {ex.Message}";
}
}
private void CalculateCrc16_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var crc = SerialChecksumHelper.CalculateCrc16(hexBytes.Value);
ChecksumResultTextBox.Text = crc.IsSuccess ? $"0x{crc.Value:X4}" : crc.ErrorMessage;
}
}
private void CalculateLrc_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var lrc = SerialChecksumHelper.CalculateLrc(hexBytes.Value);
ChecksumResultTextBox.Text = lrc.IsSuccess ? $"0x{lrc.Value:X2}" : lrc.ErrorMessage;
}
}
private void CalculateXor_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var xor = SerialChecksumHelper.CalculateXor(hexBytes.Value);
ChecksumResultTextBox.Text = xor.IsSuccess ? $"0x{xor.Value:X2}" : xor.ErrorMessage;
}
}
private void CalculateChecksum_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var checksum = SerialChecksumHelper.CalculateChecksum(hexBytes.Value);
ChecksumResultTextBox.Text = checksum.IsSuccess ? $"0x{checksum.Value:X2}" : checksum.ErrorMessage;
}
}
private async void DiagTestConnection_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在测试连接...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var options = new SerialPortOptions { PortName = portName };
var result = await SerialPortDiagnostics.TestConnectionAsync(portName, options);
DiagResultTextBox.AppendText(result.IsSuccess ? result.Value! : $"错误: {result.ErrorMessage}");
}
private async void ScanBaudRates_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在扫描波特率...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var result = await SerialPortDiagnostics.TestBaudRatesAsync(
portName, 4800, 9600, 19200, 38400, 57600, 115200);
if (result.IsSuccess && result.Value != null)
{
DiagResultTextBox.AppendText("\r\n波特率测试结果:\r\n");
foreach (var kvp in result.Value)
{
DiagResultTextBox.AppendText($" {kvp.Key,6} bps: {(kvp.Value ? "✓ 成功" : "✗ 失败")}\r\n");
}
}
else
{
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
}
}
private async void MonitorTraffic_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在监控流量 (10秒)...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var options = new SerialPortOptions { PortName = portName };
var result = await SerialPortDiagnostics.MonitorTrafficAsync(
portName,
options,
(data, isSent) => Dispatcher.Invoke(() =>
{
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
DiagResultTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {(isSent ? "发送" : "接收")}: {hexResult.Value}\r\n");
}),
TimeSpan.FromSeconds(10));
if (result.IsSuccess && result.Value != null)
{
DiagResultTextBox.AppendText($"\r\n{result.Value}\r\n");
}
else
{
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
}
}
#endregion
#region 辅助方法
private Encoding GetSelectedEncoding()
{
var encoding = ((ComboBoxItem)EncodingCombo.SelectedItem)?.Content.ToString();
return encoding switch
{
"ASCII" => Encoding.ASCII,
"GBK" => Encoding.GetEncoding("GBK"),
_ => Encoding.UTF8
};
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
// 清理资源
try
{
_driver?.Dispose();
}
catch
{
// 忽略清理异常
}
try
{
_service?.Dispose();
}
catch
{
// 忽略清理异常
}
}
#endregion
}
public class EventLogItem
{
public DateTime Time { get; set; }
public string EventType { get; set; } = string.Empty;
public string Data { get; set; } = string.Empty;
}
+138
View File
@@ -0,0 +1,138 @@
# 串口通信调试工具使用说明
## 功能概览
串口通信调试工具集成了Ports模块的所有功能,分为4个主要标签页:
## Tab 1: 串口发现
**功能:**
- 发现和查询所有可用串口
- 识别USB转串口设备
- 查看串口详细信息(描述、厂商、VID/PID)
- 检查串口是否被占用
- 测试串口连接
**操作步骤:**
1. 点击"刷新串口列表"获取所有串口
2. 点击"仅显示USB串口"过滤USB设备
3. 选择一个串口,点击"测试连接"进行连接测试
## Tab 2: 基础通信
**功能:**
- 配置串口参数(波特率、数据位、校验位、停止位)
- 打开/关闭串口连接
- 发送数据(十六进制或文本)
- 实时接收和显示数据
- 支持多种字符编码(UTF-8、ASCII、GBK)
**操作步骤:**
1. 选择串口和配置参数
2. 点击"打开串口"
3. 在发送框输入数据
- 十六进制格式:01 02 03 04
- 文本格式:直接输入文本
4. 点击"发送 (HEX)"或"发送 (文本)"
5. 接收区会实时显示收到的数据
**提示:**
- 勾选"自动添加换行符"会在文本末尾添加\r\n
- 勾选"HEX显示"会以十六进制格式显示接收数据
- 勾选"自动滚动"会自动滚动到最新数据
## Tab 3: 高级监听
**功能:**
- 使用服务层进行高级数据监听
- 自动重连机制
- 事件日志记录(数据接收、错误、状态变化)
- 实时监控连接状态
**操作步骤:**
1. 选择串口和波特率
2. 勾选"自动重连"启用断线重连
3. 点击"启动监听"
4. 事件日志会实时显示所有事件
5. 点击"停止监听"结束
**事件类型:**
- 数据接收:显示接收到的字节数和内容
- 错误:显示错误信息
- 状态变化:显示连接状态变化(连接中→已连接→断开等)
## Tab 4: 数据工具
### 数据转换
**支持的转换:**
- HEX ↔ 字节数组
- 字节 → Int16/Int32/Float
- Int32 → 字节
- 大端/小端序切换
**操作:**
1. 输入数据
2. 选择转换类型
3. 勾选"大端序"(如需要)
4. 点击"转换"
**示例:**
- 输入:`01 02 03 04`,选择"字节 → Int32",大端序 → 结果:16909060
### 校验计算
**支持的校验算法:**
- CRC16(Modbus标准)
- LRC(纵向冗余校验)
- XOR(异或校验)
- 累加和
**操作:**
1. 输入十六进制数据(如:01 02 03)
2. 点击对应的校验算法按钮
3. 查看校验结果
### 诊断工具
**功能:**
- 测试连接:测试指定串口的连接性
- 波特率扫描:自动测试多个波特率
- 流量监控:监控10秒内的数据收发
**操作:**
1. 选择要诊断的串口
2. 点击相应功能按钮
3. 在结果区查看诊断报告
## 使用技巧
1. **串口被占用?**
- 在"串口发现"页查看哪个串口被占用
- 确保其他程序没有打开该串口
2. **数据接收不完整?**
- 检查波特率设置是否正确
- 尝试使用"基础通信"的实时接收
3. **需要自动重连?**
- 使用"高级监听"标签页
- 勾选"自动重连"选项
4. **调试Modbus设备?**
- 使用"数据工具"计算CRC16
- 使用"基础通信"发送命令并查看响应
5. **不确定波特率?**
- 使用"诊断工具"的波特率扫描功能
## 常见问题
**Q: 打开串口失败?**
A: 检查串口是否被其他程序占用,或者没有权限访问该串口
**Q: 发送十六进制数据格式?**
A: 使用空格分隔,如:01 02 03 04,也支持不带空格:01020304
**Q: 如何保存接收的数据?**
A: 可以从接收框复制文本,后续版本会增加保存功能
**Q: 支持哪些操作系统?**
A: Windows(完整功能)、Linux、macOS(基础功能)
@@ -70,7 +70,7 @@ public sealed class LoginViewModel : ViewModelBase
IsBusy = true;
ErrorMessage = null;
var result = await _authenticationService.LoginAsync(UserName, Password).ConfigureAwait(false);
var result = await _authenticationService.LoginAsync(UserName, Password);
if (!result.Succeeded)
{
+104 -2
View File
@@ -5,6 +5,14 @@ using Client.Services;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
using Microsoft.Extensions.DependencyInjection;
using Common;
using Client.Hardware;
using Plc.Proxy;
using Plc.Abstractions;
using System.ComponentModel;
using Sharp.Wpf.Abstractions;
using Sharp.Wpf.DevTools.Views;
namespace Client.ViewModels;
@@ -12,23 +20,106 @@ public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private readonly IUserSession _userSession;
private readonly IDispatcher _dispatcher;
private string _greeting = "欢迎使用 Sharp";
private string _statusMessage = "系统就绪";
private DateTime _lastSyncTime = DateTime.Now;
public MainViewModel(IDialogService dialogService, IUserSession userSession)
// 魔法设备对象:直接绑定到界面,无需手动写 Read/Write/Subscribe
public IPackMachine Machine { get; }
public MainViewModel(IDialogService dialogService, IUserSession userSession, IDispatcher dispatcher)
{
_dialogService = dialogService;
_userSession = userSession;
_dispatcher = dispatcher;
_greeting = $"欢迎回来,{UserDisplayName}";
_statusMessage = "准备就绪";
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
OpenPlcToolCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<PlcWindow>();
win.Show();
});
OpenSerialPortToolCommand = new RelayCommand(() =>
{
var win = new SerialPortWindow();
win.Show();
});
OpenProcessTestCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<ProcessTestWindow>();
win.Show();
});
// DevTools 窗口命令
OpenDevToolsCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<DevToolsWindow>();
win.Show();
});
OpenLogViewerCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<LogViewerWindow>();
win.Show();
});
OpenPerformanceMonitorCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<PerformanceMonitorWindow>();
win.Show();
});
OpenAboutCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<AboutWindow>();
win.ShowDialog();
});
// 1. 获取 PLC 服务
var plcService = Global.Get<IServiceProvider>().GetRequiredService<IPlcService>();
// 2. 创建代理
Machine = PlcInterfaceProxy.Create<IPackMachine>(plcService);
// 3. 代码监听演示:监听属性变化
Machine.PropertyChanged += OnMachinePropertyChanged;
// 4. 控制命令
StartMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = true);
StopMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = false);
}
private void OnMachinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// 演示:当速度变化时,在日志或状态栏显示
if (e.PropertyName == nameof(IPackMachine.RunningSpeed))
{
_dispatcher.InvokeAsync(() =>
{
if (Machine.RunningSpeed > 1000)
{
StatusMessage = $"警告:速度过高 ({Machine.RunningSpeed})";
}
});
}
}
//...
public string UserDisplayName => _userSession.DisplayName ?? _userSession.UserName ?? "访客";
public string Greeting
@@ -52,6 +143,17 @@ public sealed class MainViewModel : ViewModelBase
public IAsyncCommand ShowDialogCommand { get; }
public IAsyncCommand RefreshDashboardCommand { get; }
public ICommand ThrowCommand { get; }
public ICommand OpenPlcToolCommand { get; }
public ICommand OpenSerialPortToolCommand { get; }
public ICommand OpenProcessTestCommand { get; }
public ICommand StartMachineCommand { get; }
public ICommand StopMachineCommand { get; }
// DevTools 命令
public ICommand OpenDevToolsCommand { get; }
public ICommand OpenLogViewerCommand { get; }
public ICommand OpenPerformanceMonitorCommand { get; }
public ICommand OpenAboutCommand { get; }
private async Task ShowDialogAsync()
{
@@ -83,4 +185,4 @@ public sealed class MainViewModel : ViewModelBase
IsBusy = false;
}
}
}
}
@@ -0,0 +1,32 @@
using Plc.Models;
using Sharp.Wpf.Mvvm;
using System;
namespace Client.ViewModels;
public class PlcTagViewModel : ViewModelBase
{
private readonly PlcTag _tag;
public PlcTagViewModel(PlcTag tag)
{
_tag = tag ?? throw new ArgumentNullException(nameof(tag));
}
public string Id => _tag.Id;
public string Address => _tag.Address;
public Type DataType => _tag.DataType;
public string DataTypeName => _tag.DataType == typeof(float) ? "Float" : _tag.DataType.Name;
public object? Value => _tag.Value;
public PlcTag Tag => _tag;
public void UpdateValue()
{
OnPropertyChanged(nameof(Value));
}
}
+268
View File
@@ -0,0 +1,268 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Commands;
using Plc.Abstractions;
using Plc.Models;
using Plc.Siemens.Options;
using S7.Net;
using System.Windows;
using Sharp.Wpf.Abstractions;
namespace Client.ViewModels;
public class PlcViewModel : ViewModelBase
{
private readonly IPlcService _plcService;
private readonly SiemensPlcOptions _siemensOptions;
private readonly IDispatcher _dispatcher;
// 连接配置
private PlcBrand _selectedBrand = PlcBrand.Siemens;
private CpuType _selectedCpuType = CpuType.S71200;
private string _ipAddress = "127.0.0.1";
private string _status = "未连接";
// 读写测试
private string _readAddress = "DB1.DBW0";
private string _readValue = "-";
private string _writeAddress = "DB1.DBW0";
private string _writeValue = "0";
// 监控
private string _monitorAddress = "DB1.DBW0";
private Type _selectedMonitorType = typeof(short);
public PlcViewModel(
IPlcService plcService,
SiemensPlcOptions siemensOptions,
IDispatcher dispatcher)
{
_plcService = plcService;
_siemensOptions = siemensOptions;
_dispatcher = dispatcher;
// 初始化界面值为当前配置值
_ipAddress = _siemensOptions.IpAddress;
_selectedCpuType = _siemensOptions.CpuType;
ConnectCommand = new AsyncCommand(ConnectAsync);
DisconnectCommand = new AsyncCommand(DisconnectAsync);
ReadCommand = new AsyncCommand(ReadAsync);
WriteCommand = new AsyncCommand(WriteAsync);
AddMonitorCommand = new RelayCommand(AddMonitor);
RemoveMonitorCommand = new RelayCommand<PlcTagViewModel>(RemoveMonitor, tag => tag != null);
}
#region Properties
public PlcBrand SelectedBrand
{
get => _selectedBrand;
set
{
if (SetProperty(ref _selectedBrand, value))
{
OnPropertyChanged(nameof(IsSiemensOptionsVisible));
}
}
}
public CpuType SelectedCpuType
{
get => _selectedCpuType;
set => SetProperty(ref _selectedCpuType, value);
}
public bool IsSiemensOptionsVisible => SelectedBrand == PlcBrand.Siemens;
public string IpAddress
{
get => _ipAddress;
set => SetProperty(ref _ipAddress, value);
}
public string Status
{
get => _status;
set => SetProperty(ref _status, value);
}
public string ReadAddress
{
get => _readAddress;
set => SetProperty(ref _readAddress, value);
}
public string ReadValue
{
get => _readValue;
set => SetProperty(ref _readValue, value);
}
public string WriteAddress
{
get => _writeAddress;
set => SetProperty(ref _writeAddress, value);
}
public string WriteValue
{
get => _writeValue;
set => SetProperty(ref _writeValue, value);
}
public string MonitorAddress
{
get => _monitorAddress;
set => SetProperty(ref _monitorAddress, value);
}
public Type SelectedMonitorType
{
get => _selectedMonitorType;
set => SetProperty(ref _selectedMonitorType, value);
}
// 数据源
public ObservableCollection<PlcBrand> Brands { get; } = new(Enum.GetValues<PlcBrand>());
public ObservableCollection<CpuType> CpuTypes { get; } = new(Enum.GetValues<CpuType>());
public ObservableCollection<DataTypeOption> SupportedMonitorTypes { get; } = new()
{
new("Bool", typeof(bool)),
new("Byte", typeof(byte)),
new("Short (Int16)", typeof(short)),
new("UShort (UInt16)", typeof(ushort)),
new("Int (Int32)", typeof(int)),
new("UInt (UInt32)", typeof(uint)),
new("Long (Int64)", typeof(long)),
new("ULong (UInt64)", typeof(ulong)),
new("Float (Real)", typeof(float)),
new("Double (LReal)", typeof(double)),
new("String", typeof(string))
};
public ObservableCollection<PlcTagViewModel> MonitoredTags { get; } = new();
#endregion
#region Commands
public IAsyncCommand ConnectCommand { get; }
public IAsyncCommand DisconnectCommand { get; }
public IAsyncCommand ReadCommand { get; }
public IAsyncCommand WriteCommand { get; }
public ICommand AddMonitorCommand { get; }
public ICommand RemoveMonitorCommand { get; }
#endregion
private async Task ConnectAsync()
{
if (SelectedBrand != PlcBrand.Siemens)
{
Status = $"暂不支持 {SelectedBrand},请等待后续更新";
return;
}
Status = "正在连接...";
try
{
// 1. 更新配置 (因为 SiemensDriver 注册为单例,且引用了 SiemensPlcOptions 单例)
// 修改这里的 Options 会直接影响 Driver 下一次 ConnectAsync 的行为
_siemensOptions.IpAddress = IpAddress;
_siemensOptions.CpuType = SelectedCpuType;
// 端口、机架号等暂时保持默认,也可以暴露在界面上
// 2. 启动服务 (内部会调用 Driver.ConnectAsync)
await _plcService.StartAsync();
Status = $"已连接 ({SelectedBrand} - {SelectedCpuType})";
}
catch (Exception ex)
{
Status = $"连接失败: {ex.Message}";
}
}
private async Task DisconnectAsync()
{
await _plcService.StopAsync();
Status = "已断开";
}
private async Task ReadAsync()
{
var result = await _plcService.ReadAsync<short>(ReadAddress);
if (result.IsSuccess)
{
ReadValue = result.Value.ToString();
}
else
{
ReadValue = $"Error: {result.ErrorMessage}";
}
}
private async Task WriteAsync()
{
if (short.TryParse(WriteValue, out var val))
{
var result = await _plcService.WriteAsync(WriteAddress, val);
if (result.IsFailure)
{
Status = $"写入失败: {result.ErrorMessage}";
}
else
{
Status = "写入成功";
}
}
else
{
Status = "输入值无效 (仅支持 short)";
}
}
private void AddMonitor()
{
var tag = new PlcTag
{
Address = MonitorAddress,
DataType = SelectedMonitorType
};
var vm = new PlcTagViewModel(tag);
tag.OnChanged = (t, v) =>
{
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
_dispatcher.InvokeAsync(() =>
{
vm.UpdateValue();
});
};
_plcService.Subscribe(tag);
// 在UI线程添加到集合
_dispatcher.InvokeAsync(() =>
{
MonitoredTags.Add(vm);
});
}
private void RemoveMonitor(PlcTagViewModel? tagVm)
{
if (tagVm == null) return;
// 从PLC服务取消订阅
_plcService.Unsubscribe(tagVm.Id);
// 从UI集合移除
_dispatcher.InvokeAsync(() =>
{
MonitoredTags.Remove(tagVm);
});
}
}
public record DataTypeOption(string Name, Type Type);
@@ -0,0 +1,510 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Windows.Input;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Abstractions;
using Sharp.Process;
using Sharp.Python;
using Microsoft.Extensions.Logging;
namespace Client.ViewModels;
/// <summary>
/// Process/Python 测试工具 ViewModel
/// </summary>
public class ProcessTestViewModel : ViewModelBase
{
private readonly IProcessExecutor _processExecutor;
private readonly IPythonExecutor _pythonExecutor;
private readonly IDispatcher _dispatcher;
private readonly ILogger<ProcessTestViewModel> _logger;
// Process 测试
private string _command = "cmd";
private string _arguments = "/c echo Hello from Process!";
private int _timeoutSeconds = 30;
private string _processOutput = "输出将显示在这里...";
private string _processStatus = "就绪";
// Python 测试
private string _pythonPath = "(自动检测)";
private string _pythonVersion = "未检测";
private string _pythonCode = "print('Hello from Python!')";
private string _pythonScriptPath = "";
private string _pythonArgs = "";
private string _pythonOutput = "输出将显示在这里...";
private string _pythonStatus = "就绪";
// 日志
public ObservableCollection<string> LogMessages { get; } = new();
public ProcessTestViewModel(
IProcessExecutor processExecutor,
IPythonExecutor pythonExecutor,
IDispatcher dispatcher,
ILogger<ProcessTestViewModel> logger)
{
_processExecutor = processExecutor;
_pythonExecutor = pythonExecutor;
_dispatcher = dispatcher;
_logger = logger;
// Process 命令
RunProcessCommand = new AsyncCommand(RunProcessAsync);
RunShellCommand = new AsyncCommand(RunShellAsync);
TestDotnetCommand = new AsyncCommand(TestDotnetAsync);
TestGitCommand = new AsyncCommand(TestGitAsync);
// Python 命令
DetectPythonCommand = new AsyncCommand(DetectPythonAsync);
RunPythonCodeCommand = new AsyncCommand(RunPythonCodeAsync);
RunPythonScriptCommand = new AsyncCommand(RunPythonScriptAsync);
TestPythonJsonCommand = new AsyncCommand(TestPythonJsonAsync);
CreateTestScriptCommand = new RelayCommand(CreateTestScript);
// 其他
ClearLogCommand = new RelayCommand(() => LogMessages.Clear());
// 自动检测 Python
_ = DetectPythonAsync();
}
#region Properties
public string Command
{
get => _command;
set => SetProperty(ref _command, value);
}
public string Arguments
{
get => _arguments;
set => SetProperty(ref _arguments, value);
}
public int TimeoutSeconds
{
get => _timeoutSeconds;
set => SetProperty(ref _timeoutSeconds, value);
}
public string ProcessOutput
{
get => _processOutput;
set => SetProperty(ref _processOutput, value);
}
public string ProcessStatus
{
get => _processStatus;
set => SetProperty(ref _processStatus, value);
}
public string PythonPath
{
get => _pythonPath;
set => SetProperty(ref _pythonPath, value);
}
public string PythonVersion
{
get => _pythonVersion;
set => SetProperty(ref _pythonVersion, value);
}
public string PythonCode
{
get => _pythonCode;
set => SetProperty(ref _pythonCode, value);
}
public string PythonScriptPath
{
get => _pythonScriptPath;
set => SetProperty(ref _pythonScriptPath, value);
}
public string PythonArgs
{
get => _pythonArgs;
set => SetProperty(ref _pythonArgs, value);
}
public string PythonOutput
{
get => _pythonOutput;
set => SetProperty(ref _pythonOutput, value);
}
public string PythonStatus
{
get => _pythonStatus;
set => SetProperty(ref _pythonStatus, value);
}
#endregion
#region Commands
public IAsyncCommand RunProcessCommand { get; }
public IAsyncCommand RunShellCommand { get; }
public IAsyncCommand TestDotnetCommand { get; }
public IAsyncCommand TestGitCommand { get; }
public IAsyncCommand DetectPythonCommand { get; }
public IAsyncCommand RunPythonCodeCommand { get; }
public IAsyncCommand RunPythonScriptCommand { get; }
public IAsyncCommand TestPythonJsonCommand { get; }
public ICommand CreateTestScriptCommand { get; }
public ICommand ClearLogCommand { get; }
#endregion
#region Process 测试
private async Task RunProcessAsync()
{
ProcessStatus = "执行中...";
ProcessOutput = "";
AddLog($"[Process] 执行命令: {Command} {Arguments}");
try
{
var options = new ProcessOptions
{
Command = Command,
Arguments = Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
OnOutputReceived = line =>
{
_dispatcher.Invoke(() =>
{
ProcessOutput += line + "\n";
OnPropertyChanged(nameof(ProcessOutput));
AddLog($"[输出] {line}");
});
},
OnErrorReceived = line =>
{
_dispatcher.Invoke(() =>
{
ProcessOutput += $"[错误] {line}\n";
OnPropertyChanged(nameof(ProcessOutput));
AddLog($"[错误] {line}");
});
}
};
var result = await _processExecutor.ExecuteAsync(options);
if (result.IsSuccess)
{
ProcessStatus = $"✓ 完成 (退出码: {result.Value.ExitCode}, 耗时: {result.Value.Duration.TotalSeconds:F2}s)";
ProcessOutput += $"\n=== 执行成功 ===\n退出码: {result.Value.ExitCode}\n耗时: {result.Value.Duration.TotalSeconds:F2}s";
AddLog($"[Process] 执行成功 (耗时: {result.Value.Duration.TotalMilliseconds}ms)");
}
else
{
ProcessStatus = $"✗ 失败: {result.ErrorMessage}";
ProcessOutput += $"\n=== 执行失败 ===\n{result.ErrorMessage}";
AddLog($"[Process] 执行失败: {result.ErrorMessage}");
}
}
catch (Exception ex)
{
ProcessStatus = $"✗ 异常: {ex.Message}";
ProcessOutput = $"异常: {ex.Message}\n{ex.StackTrace}";
AddLog($"[Process] 异常: {ex.Message}");
}
}
private async Task RunShellAsync()
{
ProcessStatus = "执行中...";
ProcessOutput = "";
var shellCmd = OperatingSystem.IsWindows()
? "dir | findstr .cs"
: "ls | grep .cs";
AddLog($"[Shell] 执行: {shellCmd}");
try
{
var result = await IProcessExecutor.RunShellAsync(shellCmd);
if (result.IsSuccess)
{
ProcessOutput = result.Value.StandardOutput;
ProcessStatus = $"✓ Shell 命令执行成功";
AddLog($"[Shell] 成功");
}
else
{
ProcessOutput = result.ErrorMessage ?? "未知错误";
ProcessStatus = $"✗ Shell 命令失败";
AddLog($"[Shell] 失败");
}
}
catch (Exception ex)
{
ProcessStatus = $"✗ 异常: {ex.Message}";
ProcessOutput = ex.Message;
AddLog($"[Shell] 异常: {ex.Message}");
}
}
private async Task TestDotnetAsync()
{
Command = "dotnet";
Arguments = "--version";
await RunProcessAsync();
}
private async Task TestGitAsync()
{
Command = "git";
Arguments = "--version";
await RunProcessAsync();
}
#endregion
#region Python 测试
private async Task DetectPythonAsync()
{
PythonStatus = "检测中...";
AddLog("[Python] 正在检测 Python 环境...");
try
{
var env = PythonEnvironment.Discover();
if (env != null)
{
PythonPath = env.PythonPath;
PythonVersion = env.Version ?? "未知版本";
PythonStatus = "✓ Python 环境已检测";
AddLog($"[Python] 找到 Python: {env.PythonPath}");
AddLog($"[Python] 版本: {env.Version}");
}
else
{
PythonPath = "未找到";
PythonVersion = "未安装";
PythonStatus = "✗ 未找到 Python";
AddLog("[Python] 未找到 Python 环境,请安装 Python 或配置 PATH");
}
}
catch (Exception ex)
{
PythonStatus = $"✗ 检测失败: {ex.Message}";
AddLog($"[Python] 检测异常: {ex.Message}");
}
await Task.CompletedTask;
}
private async Task RunPythonCodeAsync()
{
PythonStatus = "执行中...";
PythonOutput = "";
AddLog($"[Python] 执行代码: {PythonCode}");
try
{
var result = await _pythonExecutor.RunCodeAsync(PythonCode, new PythonOptions
{
OnOutputReceived = line =>
{
_dispatcher.Invoke(() =>
{
PythonOutput += line + "\n";
OnPropertyChanged(nameof(PythonOutput));
AddLog($"[Python 输出] {line}");
});
},
OnErrorReceived = line =>
{
_dispatcher.Invoke(() =>
{
PythonOutput += $"[错误] {line}\n";
OnPropertyChanged(nameof(PythonOutput));
AddLog($"[Python 错误] {line}");
});
}
});
if (result.IsSuccess)
{
if (string.IsNullOrEmpty(PythonOutput))
{
PythonOutput = result.Value.StandardOutput;
}
PythonStatus = $"✓ 执行成功 (耗时: {result.Value.Duration.TotalSeconds:F2}s)";
AddLog($"[Python] 执行成功");
}
else
{
PythonOutput = result.ErrorMessage ?? "未知错误";
PythonStatus = $"✗ 执行失败";
AddLog($"[Python] 执行失败: {result.ErrorMessage}");
}
}
catch (Exception ex)
{
PythonStatus = $"✗ 异常: {ex.Message}";
PythonOutput = ex.Message;
AddLog($"[Python] 异常: {ex.Message}");
}
}
private async Task RunPythonScriptAsync()
{
if (string.IsNullOrWhiteSpace(PythonScriptPath) || !File.Exists(PythonScriptPath))
{
PythonStatus = "✗ 请选择有效的脚本文件";
return;
}
PythonStatus = "执行中...";
PythonOutput = "";
AddLog($"[Python] 执行脚本: {PythonScriptPath}");
try
{
// 解析参数(简单的 key=value 格式)
object? args = null;
if (!string.IsNullOrWhiteSpace(PythonArgs))
{
var argsDict = new Dictionary<string, string>();
var pairs = PythonArgs.Split(',', StringSplitOptions.TrimEntries);
foreach (var pair in pairs)
{
var kv = pair.Split('=', 2);
if (kv.Length == 2)
{
argsDict[kv[0].Trim()] = kv[1].Trim();
}
}
args = argsDict;
}
var result = await _pythonExecutor.RunScriptAsync(
PythonScriptPath,
args,
new PythonOptions
{
OnOutputReceived = line =>
{
_dispatcher.Invoke(() =>
{
PythonOutput += line + "\n";
OnPropertyChanged(nameof(PythonOutput));
AddLog($"[Script] {line}");
});
}
}
);
if (result.IsSuccess)
{
if (string.IsNullOrEmpty(PythonOutput))
{
PythonOutput = result.Value.StandardOutput;
}
PythonStatus = $"✓ 脚本执行成功";
AddLog($"[Python] 脚本执行成功");
}
else
{
PythonOutput = result.ErrorMessage ?? "未知错误";
PythonStatus = $"✗ 脚本执行失败";
AddLog($"[Python] 脚本执行失败");
}
}
catch (Exception ex)
{
PythonStatus = $"✗ 异常: {ex.Message}";
PythonOutput = ex.Message;
AddLog($"[Python] 异常: {ex.Message}");
}
}
private async Task TestPythonJsonAsync()
{
PythonCode = @"import json
result = {'status': 'ok', 'value': 42, 'message': '测试成功'}
print(json.dumps(result, ensure_ascii=False))";
await RunPythonCodeAsync();
}
private void CreateTestScript()
{
var tempPath = Path.Combine(Path.GetTempPath(), "sharp_test.py");
var scriptContent = @"import sys
import json
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--name', default='World')
parser.add_argument('--count', type=int, default=1)
args = parser.parse_args()
# 日志输出到 stderr
print(f'参数: name={args.name}, count={args.count}', file=sys.stderr)
# 处理
for i in range(args.count):
print(f'Hello, {args.name}! ({i+1})', file=sys.stderr)
# 结果输出到 stdout(JSON)
result = {
'message': f'Greeted {args.name} {args.count} times',
'success': True,
'timestamp': str(__import__('datetime').datetime.now())
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
main()
";
try
{
File.WriteAllText(tempPath, scriptContent);
PythonScriptPath = tempPath;
PythonArgs = "name=Sharp,count=3";
PythonStatus = $"✓ 测试脚本已创建: {tempPath}";
AddLog($"[Python] 测试脚本已创建: {tempPath}");
}
catch (Exception ex)
{
PythonStatus = $"✗ 创建脚本失败: {ex.Message}";
AddLog($"[Python] 创建脚本失败: {ex.Message}");
}
}
#endregion
private void AddLog(string message)
{
var timestamp = DateTime.Now.ToString("HH:mm:ss");
_dispatcher.Invoke(() =>
{
LogMessages.Add($"[{timestamp}] {message}");
// 保持日志数量在合理范围
while (LogMessages.Count > 200)
{
LogMessages.RemoveAt(0);
}
});
}
}
@@ -1,4 +1,4 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0048F04EA9C102506BBFAC3803E4711F700CF3ED"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
@@ -46,7 +46,7 @@ namespace Client {
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
@@ -66,7 +66,7 @@ namespace Client {
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
public static void Main() {
Client.App app = new Client.App();
app.InitializeComponent();
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+53d1dd2dbf05d818528814223b58610a790fcb26")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+433e206d456d0ef3b2fc57c458ff3fc030643be6")]
[assembly: System.Reflection.AssemblyProductAttribute("Client")]
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
f8321da7c6a777fc7770c7234548af806317c95cd5f1beb1c1743888aa1bde11
de45fc2ddda59d691d6bfd3f2673675ae92a6f2f53cc261c9aeefe56c546545d
@@ -1,5 +1,7 @@
is_global = true
build_property.TargetFramework = net8.0-windows
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v8.0
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
@@ -8,7 +10,7 @@ build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Client
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Client\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
@@ -1,89 +0,0 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9E813BA3492F961B4CE0E3DE4E78408D972934B6"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using HandyControl.Controls;
using HandyControl.Data;
using HandyControl.Expression.Media;
using HandyControl.Expression.Shapes;
using HandyControl.Interactivity;
using HandyControl.Media.Animation;
using HandyControl.Media.Effects;
using HandyControl.Properties.Langs;
using HandyControl.Themes;
using HandyControl.Tools;
using HandyControl.Tools.Converter;
using HandyControl.Tools.Extension;
using Sharp.Wpf.Converters;
using Sharp.Wpf.Windowing;
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Client {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : HandyControl.Controls.GlowWindow, System.Windows.Markup.IComponentConnector {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Client;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
this._contentLoaded = true;
}
}
}
+7
View File
@@ -6,4 +6,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Common.Configuration;
/// <summary>
/// 系统级通用配置
/// 包含 HTTP 访问所需的基础字段,可被 NetClient/NetServer 等模块复用
/// 约定配置节名称:System
/// </summary>
public sealed class SystemConfig
{
[Required]
[RegularExpression(@"^([0-9]{1,3}\.){3}[0-9]{1,3}$|^localhost$|^[a-zA-Z0-9_.-]+$")]
public string HTTPIp { get; set; } = "127.0.0.1";
[Range(1, 65535)]
public int HttpPort { get; set; } = 8080;
[Range(1, 600)]
public int TimeoutSeconds { get; set; } = 30;
public string Scheme { get; set; } = "http";
}
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Common.DependencyInjection;
/// <summary>
/// 快速启动扩展,提供"零配置"的便捷注册方法
/// </summary>
public static class QuickStartExtensions
{
/// <summary>
/// 添加 Sharp 框架的所有基础服务(一行搞定)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置(可选,不传则自动创建默认配置)</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSharpEssentials(
this IServiceCollection services,
IConfiguration? configuration = null)
{
// 1. 配置管理(如果没传就用默认)
// Note: ConfigBuilder.CreateDefault() 在 Config 模块中
// 这里假设用户已经传入 configuration 或在外部创建
if (configuration != null)
{
services.AddSingleton(configuration);
}
// 2. 缓存服务(使用 Cache 模块的扩展方法)
// Note: AddSharpCache() 在 Cache.DependencyInjection 中定义
services.AddMemoryCache();
// 3. 全局服务定位器(兼容性)
if (configuration != null)
{
RegisterGlobalServices(services, configuration);
}
return services;
}
/// <summary>
/// 注册到 Global 容器(向后兼容)
/// </summary>
private static void RegisterGlobalServices(IServiceCollection services, IConfiguration configuration)
{
// 构建临时 ServiceProvider
var provider = services.BuildServiceProvider();
// 注册到 Global
Global.RegisterSingleton(configuration);
Global.RegisterSingleton(provider);
}
}
+4
View File
@@ -55,7 +55,11 @@ public class ServiceChangedEventArgs<T> : EventArgs
/// - 生命周期管理(Singleton/Transient)
/// - 服务变更通知机制
/// - 支持工厂方法注册
///
/// ⚠️ 注意:此类已标记为非首选使用,建议新项目使用 Microsoft.Extensions.DependencyInjection。
/// 此类仅用于向后兼容和快速原型开发。
/// </remarks>
[Obsolete("不推荐使用Global容器。建议使用Microsoft.Extensions.DependencyInjection作为首选DI方案。此类保留用于向后兼容和快速原型开发。", false)]
public static class Global
{
private class ServiceDescriptor
@@ -0,0 +1,59 @@
using Microsoft.Extensions.Configuration;
namespace Config;
public static class ConfigBuilderExtensions
{
/// <summary>
/// 创建默认配置(约定优于配置)
/// 自动查找 appsettings.json,启用热重载和环境变量
/// </summary>
/// <param name="basePath">基础路径,默认为应用程序目录</param>
/// <param name="environmentPrefix">环境变量前缀,默认为 "APP_"</param>
/// <returns>配置管理器</returns>
public static ConfigManager CreateDefault(
string? basePath = null,
string? environmentPrefix = "APP_")
{
basePath ??= AppContext.BaseDirectory;
// 查找配置文件(appsettings.json, appsettings.Development.json 等)
var configFiles = new List<string> { "appsettings.json" };
// 根据环境添加特定配置
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? "Production";
var envConfigFile = $"appsettings.{environment}.json";
var envConfigPath = Path.Combine(basePath, envConfigFile);
if (File.Exists(envConfigPath))
{
configFiles.Add(envConfigFile);
}
// 构建配置
return ConfigBuilder.BuildFromFiles(
configFiles.ToArray(),
reloadOnChange: true,
includeEnvironment: true,
environmentPrefix: environmentPrefix,
basePath: basePath
);
}
/// <summary>
/// 创建最小化配置(只有 appsettings.json)
/// </summary>
public static ConfigManager CreateMinimal(string? basePath = null)
{
basePath ??= AppContext.BaseDirectory;
return ConfigBuilder.BuildFromFiles(
new[] { "appsettings.json" },
reloadOnChange: false,
includeEnvironment: false,
basePath: basePath
);
}
}
+8 -8
View File
@@ -9,18 +9,18 @@
<ItemGroup>
<!-- EF Core -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<!-- 数据库提供者 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<!-- 依赖注入 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,101 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Sharp.Data.Repository;
namespace Data.DependencyInjection;
/// <summary>
/// 数据访问层便捷注册扩展
/// </summary>
public static class DataServiceExtensions
{
/// <summary>
/// 添加 Sharp 数据库支持(约定优于配置)
/// 自动从配置读取数据库连接信息
/// </summary>
/// <typeparam name="TContext">DbContext 类型</typeparam>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置</param>
/// <param name="configSection">配置节名称,默认为 "Database"</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSharpDatabase<TContext>(
this IServiceCollection services,
IConfiguration configuration,
string configSection = "Database")
where TContext : DbContext
{
var provider = configuration[$"{configSection}:Provider"] ?? "SqlServer";
var connectionString = configuration[$"{configSection}:ConnectionString"]
?? throw new InvalidOperationException(
$"未找到数据库连接字符串配置: {configSection}:ConnectionString");
return AddSharpDatabase<TContext>(services, provider, connectionString);
}
/// <summary>
/// 添加 Sharp 数据库支持(显式指定提供程序和连接字符串)
/// </summary>
public static IServiceCollection AddSharpDatabase<TContext>(
this IServiceCollection services,
string provider,
string connectionString)
where TContext : DbContext
{
services.AddDbContext<TContext>(options =>
{
switch (provider.ToLower())
{
case "sqlserver":
case "mssql":
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
});
break;
case "sqlite":
options.UseSqlite(connectionString);
break;
case "postgresql":
case "postgres":
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
});
break;
case "mysql":
var serverVersion = ServerVersion.AutoDetect(connectionString);
options.UseMySql(connectionString, serverVersion, mysqlOptions =>
{
mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null);
});
break;
case "inmemory":
options.UseInMemoryDatabase(connectionString);
break;
default:
throw new NotSupportedException($"不支持的数据库提供程序: {provider}");
}
// 开发环境启用详细错误
if (Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Development")
{
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging();
}
});
// 注册仓储
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
return services;
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace EventBus
{
public class Class1
{
}
}
@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
namespace Sharp.EventBus.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEventBus(this IServiceCollection services, Action<EventBusOptions>? configure = null)
{
var options = new EventBusOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton<IEventBus, InMemoryEventBus>();
return services;
}
}
+6
View File
@@ -4,6 +4,12 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Sharp.EventBus</AssemblyName>
<RootNamespace>Sharp.EventBus</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
using System;
namespace Sharp.EventBus;
public class EventBusOptions
{
/// <summary>
/// 事件队列容量,默认 1024;满时 Publish 会等待。
/// </summary>
public int ChannelCapacity { get; set; } = 1024;
/// <summary>
/// 全局错误回调。
/// </summary>
public Action<Exception, object>? OnError { get; set; }
}
+36
View File
@@ -0,0 +1,36 @@
using System;
using System.Threading.Tasks;
namespace Sharp.EventBus;
internal sealed class EventSubscription : IDisposable
{
public Type EventType { get; }
public int Priority { get; }
public Func<object, Task> Handler { get; }
public Func<object, bool>? Filter { get; }
private readonly Action<EventSubscription> _onDispose;
private bool _disposed;
public EventSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter, int priority, Action<EventSubscription> onDispose)
{
EventType = eventType;
Handler = handler;
Filter = filter;
Priority = priority;
_onDispose = onDispose;
}
public bool CanHandle(object evt)
{
if (evt.GetType() != EventType) return false;
return Filter == null || Filter(evt);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_onDispose(this);
}
}
+28
View File
@@ -0,0 +1,28 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Sharp.EventBus;
public interface IEventBus : IDisposable
{
/// <summary>
/// 异步发布事件(入队后立即返回)。
/// </summary>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default) where TEvent : class;
/// <summary>
/// 同步包装,遇到队列满或取消会抛出。
/// </summary>
void Publish<TEvent>(TEvent @event) where TEvent : class;
/// <summary>
/// 订阅事件(同步处理器)。
/// </summary>
IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class;
/// <summary>
/// 订阅事件(异步处理器)。
/// </summary>
IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class;
}
+150
View File
@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace Sharp.EventBus;
/// <summary>
/// 基于 Channel 的进程内事件总线,线程安全,按优先级顺序分发。
/// </summary>
public sealed class InMemoryEventBus : IEventBus
{
private readonly Channel<object> _channel;
private readonly ConcurrentDictionary<Type, List<EventSubscription>> _subscriptions = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _worker;
private readonly EventBusOptions _options;
public InMemoryEventBus(EventBusOptions? options = null)
{
_options = options ?? new EventBusOptions();
_channel = Channel.CreateBounded<object>(new BoundedChannelOptions(_options.ChannelCapacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait
});
_worker = Task.Run(ProcessAsync, _cts.Token);
}
public void Publish<TEvent>(TEvent @event) where TEvent : class
{
ThrowIfDisposed();
_channel.Writer.WriteAsync(@event, _cts.Token).AsTask().GetAwaiter().GetResult();
}
public Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default) where TEvent : class
{
ThrowIfDisposed();
return _channel.Writer.WriteAsync(@event, cancellationToken).AsTask();
}
public IDisposable Subscribe<TEvent>(Action<TEvent> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class
{
ThrowIfDisposed();
if (handler == null) throw new ArgumentNullException(nameof(handler));
Task Wrapper(object evt)
{
handler((TEvent)evt);
return Task.CompletedTask;
}
return AddSubscription(typeof(TEvent), Wrapper, evt => filter?.Invoke((TEvent)evt) ?? true, priority);
}
public IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler, Func<TEvent, bool>? filter = null, int priority = 0) where TEvent : class
{
ThrowIfDisposed();
if (handler == null) throw new ArgumentNullException(nameof(handler));
Task Wrapper(object evt) => handler((TEvent)evt);
return AddSubscription(typeof(TEvent), Wrapper, evt => filter?.Invoke((TEvent)evt) ?? true, priority);
}
private IDisposable AddSubscription(Type eventType, Func<object, Task> handler, Func<object, bool>? filter, int priority)
{
var sub = new EventSubscription(eventType, handler, filter, priority, RemoveSubscription);
var list = _subscriptions.GetOrAdd(eventType, _ => new List<EventSubscription>());
lock (list)
{
list.Add(sub);
list.Sort((a, b) => a.Priority.CompareTo(b.Priority));
}
return sub;
}
private void RemoveSubscription(EventSubscription subscription)
{
if (_subscriptions.TryGetValue(subscription.EventType, out var list))
{
lock (list)
{
list.Remove(subscription);
}
}
}
private async Task ProcessAsync()
{
try
{
while (await _channel.Reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
{
while (_channel.Reader.TryRead(out var evt))
{
await DispatchAsync(evt).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// 正常关闭
}
}
private async Task DispatchAsync(object evt)
{
if (_subscriptions.TryGetValue(evt.GetType(), out var list))
{
List<EventSubscription> snapshot;
lock (list)
{
snapshot = list.ToList();
}
foreach (var sub in snapshot)
{
if (!sub.CanHandle(evt)) continue;
try
{
await sub.Handler(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_options.OnError?.Invoke(ex, evt);
}
}
}
}
private void ThrowIfDisposed()
{
if (_cts.IsCancellationRequested) throw new ObjectDisposedException(nameof(InMemoryEventBus));
}
public void Dispose()
{
_cts.Cancel();
try
{
_channel.Writer.TryComplete();
_worker.Wait(TimeSpan.FromSeconds(1));
}
catch
{
// ignore
}
_cts.Dispose();
}
}
+457
View File
@@ -0,0 +1,457 @@
# Sharp.EventBus
> **🚧 开发中 / Under Construction**
轻量级进程内事件总线,用于解耦应用程序中的模块通信。
## 📋 为什么需要 EventBus?
### 场景对比
#### ❌ 不使用 EventBus - 紧耦合
```csharp
public class PlcMonitorService {
private readonly MainViewModel _mainViewModel;
private readonly ILogger _logger;
private readonly IRepository<PlcData> _repository;
// 需要注入所有订阅者,耦合严重
public PlcMonitorService(MainViewModel vm, ILogger logger, IRepository<PlcData> repo) { }
private void OnDataChanged(PlcTag tag, object value) {
_mainViewModel.UpdateValue(tag.Id, value); // UI更新
_logger.LogInformation($"Tag {tag.Id} changed"); // 日志
_repository.AddAsync(new PlcData { ... }); // 存储
}
}
```
#### ✅ 使用 EventBus - 解耦
```csharp
// 发布者:只管发布,不关心谁处理
public class PlcMonitorService {
private readonly IEventBus _eventBus;
private void OnDataChanged(PlcTag tag, object value) {
_eventBus.Publish(new PlcDataChangedEvent(tag.Id, value));
}
}
// 订阅者1:UI
public class MainViewModel {
public MainViewModel(IEventBus eventBus) {
eventBus.Subscribe<PlcDataChangedEvent>(e => UpdateValue(e.TagId, e.Value));
}
}
// 订阅者2:日志(后续添加,无需修改发布者)
public class PlcDataLogger {
public PlcDataLogger(IEventBus eventBus) {
eventBus.Subscribe<PlcDataChangedEvent>(e =>
_logger.LogInformation($"Tag {e.TagId} changed"));
}
}
```
### 核心优势
1. **解耦** - 发布者不需要知道订阅者
2. **可扩展** - 运行时动态添加/移除订阅者
3. **类型安全** - 强类型事件,编译期检查
4. **跨层通信** - ViewModels、Services、Repositories 之间自由通信
---
## 🎯 设计目标
1. **简单易用** - 一行注册,一行订阅
2. **类型安全** - 强类型事件,避免字符串魔法值
3. **性能优先** - 基于 `System.Threading.Channels`,高性能异步
4. **线程安全** - 支持多线程发布/订阅
5. **生命周期管理** - 自动清理订阅,防止内存泄漏
---
## 🚀 快速开始(未来)
### 1. 注册服务
```csharp
// App.xaml.cs
protected override void OnStartup(StartupEventArgs e) {
var services = new ServiceCollection();
// 一行注册
services.AddEventBus();
var provider = services.BuildServiceProvider();
}
```
### 2. 定义事件
```csharp
// 事件:简单的 POCO 类
public record UserLoginEvent(string Username, DateTime LoginTime);
public record PlcDataChangedEvent(string TagId, object Value);
public record OrderCreatedEvent(int OrderId, decimal TotalAmount);
```
### 3. 发布事件
```csharp
public class LoginService {
private readonly IEventBus _eventBus;
public LoginService(IEventBus eventBus) => _eventBus = eventBus;
public async Task LoginAsync(string username, string password) {
// ... 登录逻辑
// 发布事件
_eventBus.Publish(new UserLoginEvent(username, DateTime.Now));
}
}
```
### 4. 订阅事件
```csharp
// 方式1:在构造函数中订阅
public class MainViewModel : ViewModelBase {
private IDisposable? _subscription;
public MainViewModel(IEventBus eventBus) {
_subscription = eventBus.Subscribe<UserLoginEvent>(OnUserLogin);
}
private void OnUserLogin(UserLoginEvent e) {
WelcomeMessage = $"欢迎, {e.Username}!";
}
protected override void Dispose(bool disposing) {
_subscription?.Dispose(); // 取消订阅
base.Dispose(disposing);
}
}
// 方式2:异步订阅
public class DataSyncService {
public DataSyncService(IEventBus eventBus) {
eventBus.Subscribe<OrderCreatedEvent>(async e => {
await SyncToRemoteAsync(e.OrderId);
});
}
}
// 方式3:条件过滤
public class HighValueOrderMonitor {
public HighValueOrderMonitor(IEventBus eventBus) {
eventBus.Subscribe<OrderCreatedEvent>(e => e.TotalAmount > 10000, e => {
// 只处理大额订单
NotifyManager(e);
});
}
}
```
---
## 📚 核心 API 设计
### IEventBus 接口
```csharp
public interface IEventBus {
/// <summary>
/// 发布事件(同步)
/// </summary>
void Publish<TEvent>(TEvent @event) where TEvent : class;
/// <summary>
/// 发布事件(异步)
/// </summary>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default) where TEvent : class;
/// <summary>
/// 订阅事件(同步处理器)
/// </summary>
IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
/// <summary>
/// 订阅事件(异步处理器)
/// </summary>
IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler) where TEvent : class;
/// <summary>
/// 订阅事件(带过滤条件)
/// </summary>
IDisposable Subscribe<TEvent>(
Func<TEvent, bool> filter,
Action<TEvent> handler) where TEvent : class;
/// <summary>
/// 订阅事件(带优先级,数字越小优先级越高)
/// </summary>
IDisposable Subscribe<TEvent>(
Action<TEvent> handler,
int priority = 0) where TEvent : class;
}
```
### EventSubscription 类(流式API)
```csharp
public class EventSubscription<TEvent> : IDisposable where TEvent : class {
/// <summary>
/// 添加过滤条件
/// </summary>
public EventSubscription<TEvent> Where(Func<TEvent, bool> predicate);
/// <summary>
/// 设置优先级
/// </summary>
public EventSubscription<TEvent> WithPriority(int priority);
/// <summary>
/// 在指定线程上执行(如UI线程)
/// </summary>
public EventSubscription<TEvent> OnThread(TaskScheduler scheduler);
/// <summary>
/// 延迟执行
/// </summary>
public EventSubscription<TEvent> Delay(TimeSpan delay);
/// <summary>
/// 最终处理器
/// </summary>
public IDisposable Handle(Action<TEvent> handler);
public IDisposable HandleAsync(Func<TEvent, Task> handler);
}
// 使用示例
eventBus.CreateSubscription<PlcDataChangedEvent>()
.Where(e => e.TagId.StartsWith("Temperature"))
.Delay(TimeSpan.FromSeconds(1)) // 防抖
.OnThread(TaskScheduler.FromCurrentSynchronizationContext()) // UI线程
.Handle(e => UpdateChart(e.Value));
```
---
## 🔧 高级功能
### 1. 事件优先级
```csharp
// 优先级:数字越小越先执行
eventBus.Subscribe<OrderCreatedEvent>(e => ValidateOrder(e), priority: 1);
eventBus.Subscribe<OrderCreatedEvent>(e => SendEmail(e), priority: 10);
eventBus.Subscribe<OrderCreatedEvent>(e => UpdateStatistics(e), priority: 20);
```
### 2. 弱引用订阅(防止内存泄漏)
```csharp
// 自动模式:订阅者被GC回收时自动取消订阅
eventBus.SubscribeWeak<UserLoginEvent>(viewModel, (vm, e) => vm.OnLogin(e));
```
### 3. 延迟发布
```csharp
// 延迟1秒发布(防抖场景)
eventBus.PublishDelayed(new SearchTextChangedEvent(text), TimeSpan.FromSeconds(1));
```
### 4. 错误处理
```csharp
// 全局错误处理器
services.AddEventBus(options => {
options.OnError = (exception, @event) => {
logger.LogError(exception, "事件处理失败: {@Event}", @event);
};
});
// 单个订阅的错误处理
eventBus.Subscribe<OrderCreatedEvent>(e => {
try {
ProcessOrder(e);
} catch (Exception ex) {
logger.LogError(ex, "订单处理失败");
// 不影响其他订阅者
}
});
```
### 5. 事件日志(可选)
```csharp
services.AddEventBus(options => {
options.EnableEventLogging = true; // 记录所有事件发布
options.LogEventData = true; // 记录事件数据(调试用)
});
```
---
## 💡 典型使用场景
### 场景1:跨 ViewModel 通信
```csharp
// LoginViewModel 发布登录成功事件
public class LoginViewModel {
private async Task LoginAsync() {
// ... 登录逻辑
_eventBus.Publish(new UserLoginSuccessEvent(user));
}
}
// MainViewModel 订阅并更新UI
public class MainViewModel {
public MainViewModel(IEventBus eventBus) {
eventBus.Subscribe<UserLoginSuccessEvent>(e => {
CurrentUser = e.User;
LoadUserData();
});
}
}
```
### 场景2:PLC数据监控
```csharp
// PLC监控服务发布数据变化
public class PlcMonitorService {
private void OnTagChanged(PlcTag tag, object value) {
_eventBus.Publish(new PlcDataChangedEvent(tag.Id, value));
}
}
// UI订阅更新界面
public class DashboardViewModel {
public DashboardViewModel(IEventBus eventBus) {
eventBus.Subscribe<PlcDataChangedEvent>(
e => e.TagId == "Temperature1",
e => Temperature = (double)e.Value
);
}
}
// 数据记录服务订阅存储数据
public class DataLoggerService {
public DataLoggerService(IEventBus eventBus) {
eventBus.Subscribe<PlcDataChangedEvent>(async e => {
await _repository.AddAsync(new DataLog {
TagId = e.TagId,
Value = e.Value,
Timestamp = DateTime.Now
});
});
}
}
```
### 场景3:报警系统
```csharp
// 报警事件
public record AlarmTriggeredEvent(string AlarmId, AlarmLevel Level, string Message);
// 多个订阅者处理报警
eventBus.Subscribe<AlarmTriggeredEvent>(e => e.Level >= AlarmLevel.High, e => {
PlayAlarmSound(); // UI线程
});
eventBus.Subscribe<AlarmTriggeredEvent>(async e => {
await SendEmailToManager(e); // 异步发送邮件
});
eventBus.Subscribe<AlarmTriggeredEvent>(e => {
LogAlarm(e); // 记录日志
});
```
---
## ⚙️ 配置选项
```csharp
services.AddEventBus(options => {
// 通道容量(默认1000)
options.ChannelCapacity = 10000;
// 错误处理
options.OnError = (ex, evt) => logger.LogError(ex, "事件错误");
// 事件日志
options.EnableEventLogging = true;
// 超时配置(异步处理器超时时间)
options.HandlerTimeout = TimeSpan.FromSeconds(30);
});
```
---
## 🏗️ 实现方案
### 基于 System.Threading.Channels
- **高性能**:异步无锁队列
- **线程安全**:内置并发支持
- **背压支持**:通道容量控制
### 架构设计
```
IEventBus(接口)
↓
InMemoryEventBus(默认实现)
↓ 内部使用
System.Threading.Channels.Channel<EventEnvelope>
```
---
## 📊 性能目标
- **发布延迟**:< 1ms(同步),< 10ms(异步)
- **吞吐量**:> 100,000 事件/秒
- **内存占用**:最小化,弱引用订阅防止泄漏
- **线程安全**:完全线程安全
---
## 🔄 与其他模式对比
| 模式 | 优点 | 缺点 | 适用场景 |
|-----|------|------|---------|
| **EventBus** | 完全解耦、多订阅者 | 略微增加复杂度 | 跨模块通信、插件架构 |
| **直接回调** | 简单直接 | 紧耦合、单订阅者 | 简单父子组件通信 |
| **事件聚合器** | 解耦 | 需要手动管理 | 中等复杂度应用 |
| **消息队列** | 跨进程、持久化 | 重量级、需要中间件 | 分布式系统 |
---
## 📝 注意事项
1. **不要滥用**:简单的父子组件通信用直接回调即可
2. **事件命名**:使用过去式(如 `UserLoggedInEvent` 而非 `UserLoginEvent`)
3. **事件不可变**:推荐使用 `record` 定义事件
4. **及时取消订阅**:避免内存泄漏
5. **异常处理**:订阅者内部处理异常,不要抛出
---
## 🚀 未来扩展
### 可能的扩展方向
1. **分布式 EventBus**(基于 Redis Pub/Sub)
2. **事件溯源**(Event Sourcing)支持
3. **事件重放**(Replay)功能
4. **事件转换**(Transform)中间件
---
## 📖 参考资料
- [MediatR](https://github.com/jbogard/MediatR) - .NET 消息中介库
- [Prism EventAggregator](https://prismlibrary.com/docs/event-aggregator.html) - WPF MVVM 事件聚合器
- [System.Threading.Channels](https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/) - .NET 高性能通道
---
**状态**:设计阶段,未实现
**预计实现时间**:根据实际需求决定
**当前替代方案**:使用 Common 模块的 Global 容器 + 委托事件
+82
View File
@@ -0,0 +1,82 @@
# Sharp Project Context
## Project Overview
**Sharp** is a modular C# .NET 8 solution designed for industrial automation and network services. It features a "Spring Boot-like" HTTP server framework, a robust PLC communication library, and WPF-based client/server applications. The architecture emphasizes dependency injection, functional error handling (Result pattern), and clean separation of concerns.
## Key Modules
### Core Infrastructure
- **Common**: A utility library providing a lightweight DI container (`Global`), functional `Result<T>` pattern, and extensions for Strings, DateTime, Encryption, and Reflection.
- **Config**: Configuration management.
- **Log**: Integration with `Microsoft.Extensions.Logging`.
- **EventBus**: Event-driven architecture components.
### Networking & Communications
- **NetServer**: A lightweight, attribute-based HTTP server framework built on `HttpListener`. Features include:
- Routing (`[Route]`, `[HttpGet]`), Authentication (`[Authorize]`), Rate Limiting, and Middleware support.
- **NetClient**: Typed HTTP client abstractions.
- **Plc**: Industrial communication abstraction layer.
- **Plc.Siemens**: Implementation for Siemens S7 CPUs using optimized batch reading strategies (merging adjacent tags to reduce network overhead).
- **Ports**: Serial/COM port abstractions.
### Applications (UI)
- **Client**: Main WPF Client application.
- **Server**: WPF Server application (likely a dashboard or local control interface).
- **Wpf**: Shared WPF UI logic, behaviors, and converters.
## Development Workflow
### Prerequisites
- .NET 8 SDK
- Visual Studio 2022 or VS Code (with C# Dev Kit)
### Building & Running
- **Restore Dependencies**:
```bash
dotnet restore
```
- **Build Solution**:
```bash
dotnet build Sharp.sln -c Debug
```
- **Run WPF Client**:
```bash
dotnet run --project Client/Client.csproj
```
- **Run NetServer Example**:
```bash
dotnet run --project NetServer/Examples
# Note: Check specific project path if Examples is a folder or project
```
### Testing
- Tests are located in the `../../test/` directory (relative to `src/Sharp`).
- **Run All Tests**:
```bash
dotnet test Sharp.sln
```
- **Run Specific Module Tests**:
```bash
dotnet test ../../test/Common.Test/Common.Test.csproj
```
## Code Conventions & Standards
- **Language**: C# 12 / .NET 8 (`net8.0-windows` for UI).
- **Naming**:
- `PascalCase` for public members and types.
- `camelCase` for private fields and local variables.
- Async methods must end with `Async`.
- Interfaces prefixed with `I`.
- **Architecture**:
- Prefer **Dependency Injection** (DI) via constructor injection.
- Use the **Result Pattern** (`Result<T>`) from `Common` for flow control instead of exceptions for business logic.
- **Plc Communication**: Use `IPlcService` and `PlcTag` for managed, optimized data access.
- **Git Commit Messages**:
- Concise and imperative (e.g., `feat: add plc reconnection logic` or `修复: 优化批量读取算法`).
- Chinese commit messages are acceptable and common in history.
## Important Implementation Details
- **Plc Optimization**: The `SiemensOptimizer` automatically batches read requests based on `MaxGapBytes` (default 20 bytes) and PDU size to maximize throughput.
- **NetServer Middleware**: Middleware order matters. Default pipeline: Exception -> Logging -> Cors -> Auth -> RateLimit -> Cache -> Route.
- **Common Utilities**: extensively use `Global` for service location in legacy/static contexts, but prefer DI in modern components.
-7
View File
@@ -1,7 +0,0 @@
namespace NetClient
{
public class Class1
{
}
}
@@ -2,7 +2,7 @@ using System;
using Microsoft.Extensions.DependencyInjection;
using Config;
using NetClient.Options;
using Sharp.Infrastructure.Configuration;
using Common.Configuration;
namespace NetClient.DependencyInjection;
+3 -4
View File
@@ -7,16 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Config\Config.csproj" />
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using Config;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sharp.Infrastructure.Configuration;
using Common.Configuration;
using Sharp.NetServer.Server;
namespace Sharp.NetServer.DependencyInjection;
@@ -1,3 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Text;
using Sharp.NetServer.Abstractions;
@@ -45,31 +46,48 @@ public class ResponseCacheMiddleware : IMiddleware
}
// 捕获响应
var originalStream = context.Response.OutputStream;
using var memoryStream = new MemoryStream();
// 临时替换输出流
var response = context.Response;
var originalContentType = response.ContentType;
var originalStatusCode = response.StatusCode;
using var captured = new MemoryStream();
var originalStream = response.SwapOutputStream(captured);
// 执行下一个中间件
await next();
// 如果响应成功,缓存结果
if (response.StatusCode == 200)
try
{
// 注意:由于 HttpListenerResponse 的限制,我们无法直接拦截输出流
// 这里提供一个简化的实现,实际使用中可能需要在控制器层面配合
var cachedData = new CachedResponse
{
ContentType = response.ContentType ?? "application/json",
StatusCode = response.StatusCode,
Headers = new Dictionary<string, string>(),
// Body 需要在控制器中通过特殊处理来缓存
};
await next();
await _cache.SetAsync(cacheKey, cachedData, TimeSpan.FromSeconds(config.Duration));
// 只处理捕获后的数据
var bodyBytes = captured.ToArray();
response.ContentLength = bodyBytes.Length;
// MISS 标记
response.AddHeader("X-Cache", "MISS");
// 写回真实输出
await response.FlushCapturedBodyAsync(captured, context.CancellationToken);
if (response.StatusCode == 200)
{
var headers = new Dictionary<string, string>();
foreach (var key in response.Headers.AllKeys)
{
if (key is null) continue;
headers[key] = response.Headers[key]!;
}
var cachedData = new CachedResponse
{
ContentType = response.ContentType ?? "application/json",
StatusCode = response.StatusCode,
Headers = headers,
Body = bodyBytes
};
await _cache.SetAsync(cacheKey, cachedData, TimeSpan.FromSeconds(config.Duration));
}
}
finally
{
// 还原输出流,避免影响后续逻辑
response.SwapOutputStream(originalStream);
}
}
@@ -111,11 +129,9 @@ public class ResponseCacheMiddleware : IMiddleware
context.Response.AddHeader(header.Key, header.Value);
}
if (cachedResponse.Body != null)
{
context.Response.ContentLength = cachedResponse.Body.Length;
await context.Response.OutputStream.WriteAsync(cachedResponse.Body);
}
var body = cachedResponse.Body ?? Array.Empty<byte>();
context.Response.ContentLength = body.Length;
await context.Response.OutputStream.WriteAsync(body);
}
}
+26 -1
View File
@@ -1,6 +1,7 @@
using System.Collections.Specialized;
using System.Net;
using System.Security.Claims;
using System.Threading;
namespace Sharp.NetServer.Models;
@@ -101,10 +102,12 @@ public class HttpRequestWrapper
public class HttpResponseWrapper
{
private readonly HttpListenerResponse _response;
private Stream _outputStream;
public HttpResponseWrapper(HttpListenerResponse response)
{
_response = response;
_outputStream = response.OutputStream;
}
public int StatusCode
@@ -125,7 +128,7 @@ public class HttpResponseWrapper
set => _response.ContentLength64 = value;
}
public Stream OutputStream => _response.OutputStream;
public Stream OutputStream => _outputStream;
public WebHeaderCollection Headers => _response.Headers;
public CookieCollection Cookies => _response.Cookies;
@@ -134,6 +137,28 @@ public class HttpResponseWrapper
_response.AddHeader(name, value);
}
/// <summary>
/// 替换输出流(用于中间件包装),返回之前的流。
/// </summary>
public Stream SwapOutputStream(Stream stream)
{
var previous = _outputStream;
_outputStream = stream;
return previous;
}
/// <summary>
/// 将捕获的内容写回到底层 HttpListener 输出流。
/// </summary>
public async Task FlushCapturedBodyAsync(Stream captured, CancellationToken cancellationToken = default)
{
if (captured.CanSeek)
{
captured.Position = 0;
}
await captured.CopyToAsync(_response.OutputStream, cancellationToken).ConfigureAwait(false);
}
public void Redirect(string url)
{
_response.Redirect(url);
+3 -4
View File
@@ -10,13 +10,12 @@
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Config\Config.csproj" />
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,83 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Plc.Abstractions;
using Plc.Services;
using Plc.Siemens;
using Plc.Siemens.Options;
using Plc.Options;
namespace Plc.Siemens.DependencyInjection;
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加西门子PLC服务到DI容器
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configureOptions">配置选项的委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSiemensPlc(
this IServiceCollection services,
Action<SiemensPlcOptions> configureOptions)
{
// 使用标准的 IOptions 模式
services.Configure(configureOptions);
// 同时注册 SiemensPlcOptions 单例,供需要直接访问的地方使用(如 PlcViewModel)
// 注意:这是一个共享实例,从 IOptions 获取值
services.AddSingleton(sp => sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value);
// 将 SiemensPlcOptions.MonitorInterval 映射到 PlcMonitorOptions
services.Configure<PlcMonitorOptions>(options =>
{
// 这里我们无法直接获取 IOptions<SiemensPlcOptions>,因为 Configure 是在构建容器前执行的
// 所以我们创建一个临时的 SiemensPlcOptions 来获取配置
// 但更好的方式是让 PlcMonitorOptions 的配置源自 SiemensPlcOptions 的配置源
// 简单起见,我们在这里通过回调再次执行 configureOptions
var tempOptions = new SiemensPlcOptions();
configureOptions(tempOptions);
options.RefreshRate = TimeSpan.FromMilliseconds(tempOptions.MonitorInterval);
});
// 注意:上面的映射方式有一个小缺陷,如果 SiemensPlcOptions 是通过 appsettings.json 绑定的,
// 那么这里的 configureOptions 可能只是部分配置或为空。
// 更稳健的方式是使用 PostConfigure
services.PostConfigure<PlcMonitorOptions>(options =>
{
// 这种方式需要通过 ServiceProvider 获取 SiemensPlcOptions,但在 PostConfigure 中拿不到 ServiceProvider
// 所以通常我们在注册服务工厂时手动转换,或者使用 OptionsMonitor
});
// 修正:在注册 IPlcService 时,其实不需要显式配置 PlcMonitorOptions,
// 只要我们确保 PlcMonitorOptions 被正确配置即可。
// 由于 configureOptions 委托是用户传进来的,我们再次利用它配置 PlcMonitorOptions 是最直接的。
// 但如果用户用了 Configuration Bind,那么 configureOptions 可能是空的。
// 更好的方案:使用 IPostConfigureOptions<PlcMonitorOptions>
services.AddSingleton<IPostConfigureOptions<PlcMonitorOptions>, SiemensPlcMonitorOptionsConfigurator>();
// 注册驱动程序(使用 IOptions)
services.AddSingleton<IPlcDriver>(sp =>
{
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
return new SiemensDriver(options);
});
// 注册优化器(使用 IOptions 中的 MaxGapBytes)
services.AddSingleton<IPlcOptimizer>(sp =>
{
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<SiemensOptimizer>>();
return new SiemensOptimizer(options.MaxGapBytes, logger);
});
// 注册数据转换器
services.AddSingleton<IDataConverter, BigEndianConverter>();
// 注册PLC服务
services.AddSingleton<IPlcService, PlcMonitorService>();
return services;
}
}
@@ -0,0 +1,27 @@
using Plc.Abstractions;
namespace Plc.Siemens.Models;
public enum SiemensMemoryArea
{
Inputs = 0x81,
Outputs = 0x82,
Memory = 0x83,
DataBlock = 0x84,
Timer = 0x1D,
Counter = 0x1C
}
public class SiemensAddress : IPlcAddress
{
public SiemensMemoryArea Area { get; set; }
public int DbNumber { get; set; } // Only for DataBlock
public int ByteOffset { get; set; }
public int BitOffset { get; set; }
// Helper to sort/group
public override string ToString()
{
return $"{Area}:{DbNumber}:{ByteOffset}.{BitOffset}";
}
}
@@ -0,0 +1,20 @@
using Microsoft.Extensions.Options;
using Plc.Options;
namespace Plc.Siemens.Options;
public class SiemensPlcMonitorOptionsConfigurator : IPostConfigureOptions<PlcMonitorOptions>
{
private readonly IOptionsMonitor<SiemensPlcOptions> _siemensOptions;
public SiemensPlcMonitorOptionsConfigurator(IOptionsMonitor<SiemensPlcOptions> siemensOptions)
{
_siemensOptions = siemensOptions;
}
public void PostConfigure(string? name, PlcMonitorOptions options)
{
// 从 SiemensPlcOptions 同步 MonitorInterval 到 PlcMonitorOptions
options.RefreshRate = TimeSpan.FromMilliseconds(_siemensOptions.CurrentValue.MonitorInterval);
}
}
@@ -0,0 +1,75 @@
using S7.Net;
using System.ComponentModel.DataAnnotations;
using System.Net;
namespace Plc.Siemens.Options;
public class SiemensPlcOptions : IValidatableObject
{
[Required]
public string IpAddress { get; set; } = "127.0.0.1";
[Range(1, 65535)]
public int Port { get; set; } = 102;
[Range(0, 255)]
public short Rack { get; set; } = 0;
[Range(0, 255)]
public short Slot { get; set; } = 1;
/// <summary>
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
/// 默认保守值设为 240。
/// </summary>
[Range(128, 960)]
public int PduSize { get; set; } = 240;
/// <summary>
/// CPU 类型 (例如: S71200, S71500, S7300, S7400, S7200, S7200Smart).
/// 默认为 S71200。
/// </summary>
public CpuType CpuType { get; set; } = CpuType.S71200;
/// <summary>
/// 优化器允许的最大间隔字节数。
/// 两个标签之间如果间隔小于此值,会合并为一次读取。
/// 默认为 20。
/// </summary>
[Range(0, 1000)]
public int MaxGapBytes { get; set; } = 20;
/// <summary>
/// 监控循环刷新率 (毫秒)。
/// 默认为 200ms。
/// </summary>
[Range(10, 60000)]
public int MonitorInterval { get; set; } = 200;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// 验证IP地址格式
if (!IPAddress.TryParse(IpAddress, out _))
{
yield return new ValidationResult(
$"Invalid IP address format: {IpAddress}",
new[] { nameof(IpAddress) });
}
// 验证PDU大小合理性
if (PduSize < 128 || PduSize > 960)
{
yield return new ValidationResult(
$"PDU size must be between 128 and 960, got: {PduSize}",
new[] { nameof(PduSize) });
}
// 验证MaxGapBytes的合理性
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
{
yield return new ValidationResult(
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
new[] { nameof(MaxGapBytes) });
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plc\Plc.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="S7netplus" Version="0.20.0" />
</ItemGroup>
</Project>
+677
View File
@@ -0,0 +1,677 @@
# Plc.Siemens - 西门子S7 PLC通信驱动
[![.NET](https://img.shields.io/badge/.NET-8.0-blue)](https://dotnet.microsoft.com/)
[![S7netplus](https://img.shields.io/badge/S7netplus-0.20.0-orange)](https://github.com/S7NetPlus/s7netplus)
## 📖 概述
Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库的西门子S7系列PLC通信驱动模块,实现了 `Plc` 核心库的抽象接口,提供了对西门子 S7-200、S7-300、S7-400、S7-1200、S7-1500 系列PLC的完整支持。
### 核心特性
- ✅ **全系列支持** - S7-200/300/400/1200/1500
- ✅ **S7协议** - 基于标准 S7 通信协议
- ✅ **地址智能解析** - 支持多种西门子地址格式
- ✅ **批量优化** - 针对S7协议的智能批量读取
- ✅ **大端序转换** - 自动处理字节序
- ✅ **完整配置** - 支持所有连接参数
- ✅ **类型安全** - 严格的配置验证
---
## 🎯 支持的PLC型号
| 系列 | CPU类型 | 默认端口 | PDU大小 | 测试状态 |
|------|---------|---------|---------|---------|
| S7-200 | S7200 | 102 | 240 | ✅ 已测试 |
| S7-300 | S7300 | 102 | 240 | ✅ 已测试 |
| S7-400 | S7400 | 102 | 480 | ✅ 已测试 |
| S7-1200 | S71200 | 102 | 960 | ✅ 已测试 |
| S7-1500 | S71500 | 102 | 960 | ✅ 已测试 |
| S7-200 Smart | S7200Smart | 102 | 240 | ⚠️ 未测试 |
---
## 🚀 快速开始
### 安装
```bash
dotnet add package YourCompany.Plc.Siemens
```
### 基本配置
```csharp
using Plc.Siemens.DependencyInjection;
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.Rack = 0;
options.Slot = 1;
options.PduSize = 960; // S7-1200/1500: 960, S7-300: 240
options.MaxGapBytes = 20; // 批量优化间隔阈值
options.MonitorInterval = 200; // 监控循环刷新率 (毫秒)
});
```
> **注意**:`MonitorInterval` 参数会被自动映射到核心服务 `PlcMonitorOptions.RefreshRate`。这是通过内部的 `Configure<PlcMonitorOptions>` 和 `IPostConfigureOptions` 机制实现的,用户无需手动配置核心选项。
### 完整示例
```csharp
using Plc.Abstractions;
using Plc.Models;
public class PlcController
{
private readonly IPlcService _plcService;
public PlcController(IPlcService plcService)
{
_plcService = plcService;
}
public async Task RunExample()
{
// 1. 启动连接
await _plcService.StartAsync();
// 2. 读取数据块
var temp = await _plcService.ReadAsync<float>("DB1.DBD0");
Console.WriteLine($"温度: {temp.Value}°C");
// 3. 写入数据
await _plcService.WriteAsync("DB1.DBW10", (short)100);
// 4. 读取位
var alarm = await _plcService.ReadAsync<bool>("M0.5");
Console.WriteLine($"报警状态: {alarm.Value}");
// 5. 订阅监控
var tag = new PlcTag
{
Address = "DB1.DBD0",
DataType = typeof(float),
OnChanged = (t, v) => Console.WriteLine($"温度变化: {v}°C")
};
_plcService.Subscribe(tag);
// 6. 等待一段时间观察变化
await Task.Delay(10000);
// 7. 停止服务
await _plcService.StopAsync();
}
}
```
---
## 📍 西门子地址格式
### 支持的存储区域
| 区域 | 标识符 | 说明 | 示例 |
|------|--------|------|------|
| 数据块 (DB) | DB | Data Block | `DB1.DBW0` |
| 标志位 (M) | M | Memory/Flags | `M100.0` |
| 输入 (I) | I | Input | `I0.0` |
| 输出 (Q) | Q | Output | `Q1.5` |
| 定时器 (T) | T | Timer | `T0` |
| 计数器 (C) | C | Counter | `C5` |
### 数据类型标识
| 标识符 | 类型 | 字节数 | C# 类型 | 示例 |
|--------|------|--------|---------|------|
| DBX | Bool (位) | 1 bit | `bool` | `DB1.DBX0.0` |
| DBB | Byte | 1 | `byte` | `DB1.DBB0` |
| DBW | Word | 2 | `short/ushort` | `DB1.DBW0` |
| DBD | DWord | 4 | `int/uint/float` | `DB1.DBD0` |
### 地址格式详解
#### 1. 数据块 (DB)
```csharp
// 格式: DB{块号}.DB{类型}{偏移}[.{位}]
"DB1.DBX0.5" // DB1, 字节0, 位5 (bool)
"DB1.DBB0" // DB1, 字节0 (byte)
"DB1.DBW0" // DB1, 字0 (起始字节0, 长度2) (short/ushort)
"DB1.DBD0" // DB1, 双字0 (起始字节0, 长度4) (int/uint/float)
"DB100.DBD500" // DB100, 双字500 (起始字节500)
```
#### 2. 标志位/内部存储器 (M)
```csharp
// 格式: M{字节}[.{位}] 或 M{类型}{字节}
"M0.0" // 标志位 M0.0 (bool)
"M0.7" // 标志位 M0.7 (bool)
"MB10" // 标志字节 MB10 (byte)
"MW100" // 标志字 MW100 (short/ushort)
"MD200" // 标志双字 MD200 (int/uint/float)
```
#### 3. 输入 (I) 和输出 (Q)
```csharp
// 输入区域
"I0.0" // 输入位 I0.0
"IB0" // 输入字节 IB0
"IW0" // 输入字 IW0
// 输出区域
"Q1.5" // 输出位 Q1.5
"QB10" // 输出字节 QB10
"QW20" // 输出字 QW20
```
### 地址解析示例
```csharp
// 西门子地址解析器会自动识别格式
var examples = new[]
{
("DB1.DBX0.0", "数据块1, 字节0, 位0"),
("DB1.DBB0", "数据块1, 字节0"),
("DB1.DBW0", "数据块1, 字0 (字节0-1)"),
("DB1.DBD0", "数据块1, 双字0 (字节0-3)"),
("M100.5", "标志位 M100.5"),
("MW100", "标志字 MW100 (字节100-101)"),
("I0.0", "输入位 I0.0"),
("Q1.5", "输出位 Q1.5")
};
```
---
## ⚙️ 配置选项
### SiemensPlcOptions 详解
```csharp
public class SiemensPlcOptions
{
/// <summary>
/// PLC IP地址
/// 验证: 必须是有效的IPv4地址
/// 默认: "127.0.0.1"
/// </summary>
public string IpAddress { get; set; }
/// <summary>
/// 通信端口
/// 验证: 1-65535
/// 默认: 102 (S7标准端口)
/// </summary>
public int Port { get; set; }
/// <summary>
/// 机架号
/// 验证: 0-255
/// 默认: 0
/// </summary>
public short Rack { get; set; }
/// <summary>
/// 插槽号
/// S7-300/400: 通常为 2
/// S7-1200/1500: 通常为 1
/// 验证: 0-255
/// 默认: 1
/// </summary>
public short Slot { get; set; }
/// <summary>
/// PDU (协议数据单元) 大小
/// S7-1200/1500: 最大 960
/// S7-300/400: 最大 480
/// S7-200: 最大 240
/// 验证: 128-960
/// 默认: 240 (保守值)
/// </summary>
public int PduSize { get; set; }
/// <summary>
/// CPU 类型
/// 默认: S71200
/// </summary>
public CpuType CpuType { get; set; }
/// <summary>
/// 批量优化最大间隔字节数
/// 两个标签之间如果间隔小于此值,会合并为一次读取
/// 验证: 0-1000
/// 默认: 20
/// </summary>
public int MaxGapBytes { get; set; }
/// <summary>
/// 监控循环刷新率 (毫秒)
/// 验证: 10-60000
/// 默认: 200
/// </summary>
public int MonitorInterval { get; set; }
}
```
### 配置验证
配置类实现了 `IValidatableObject`,会在启动时自动验证:
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "invalid-ip"; // ❌ 验证失败
options.Port = 70000; // ❌ 端口超出范围
options.PduSize = 2000; // ❌ PDU过大
});
// 启动时会抛出 OptionsValidationException
```
### 针对不同型号的推荐配置
#### S7-1200 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.Rack = 0;
options.Slot = 1; // S7-1200 通常是槽位1
options.PduSize = 960; // S7-1200 支持最大960
options.MaxGapBytes = 30;
});
```
#### S7-1500 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.200";
options.CpuType = S7.Net.CpuType.S71500;
options.Port = 102;
options.Rack = 0;
options.Slot = 1; // S7-1500 通常是槽位1
options.PduSize = 960; // S7-1500 支持最大960
options.MaxGapBytes = 50;
});
```
#### S7-300 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.50";
options.CpuType = S7.Net.CpuType.S7300;
options.Port = 102;
options.Rack = 0;
options.Slot = 2; // S7-300 通常是槽位2
options.PduSize = 240; // S7-300 支持最大240
options.MaxGapBytes = 20;
});
```
---
## 🎨 高级功能
### 1. 批量优化原理
`SiemensOptimizer` 针对S7协议优化批量读取:
```csharp
// 假设订阅了以下标签
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) }, // 字节 0-1
new PlcTag { Address = "DB1.DBW4", DataType = typeof(short) }, // 字节 4-5
new PlcTag { Address = "DB1.DBW8", DataType = typeof(short) }, // 字节 8-9
new PlcTag { Address = "DB1.DBW100", DataType = typeof(short) }, // 字节 100-101
};
// 优化器会生成2个批次:
// 批次1: DB1.DBW0, 10字节 (包含字节0-9, 覆盖前3个标签)
// 批次2: DB1.DBW100, 2字节 (第4个标签距离太远)
```
**优化规则:**
1. 按 **存储区域** 和 **DB号** 分组
2. 按 **字节偏移** 排序
3. 间隔 ≤ `MaxGapBytes` 的标签合并
4. 单次读取 ≤ `PduSize` 限制
### 2. 字节序处理
西门子PLC使用 **大端序 (Big-Endian)**:
```csharp
// PLC中的字节序: [00] [64]
// 读取为 short: 0x0064 = 100
var value = await _plcService.ReadAsync<short>("DB1.DBW0");
// BigEndianConverter 自动转换
// 结果: value = 100
```
**支持的转换:**
- ✅ Bool (按位)
- ✅ Byte
- ✅ Short / UShort (2字节大端)
- ✅ Int / UInt / Float (4字节大端)
- ✅ Double (8字节大端)
- ✅ String (S7格式: [MaxLen][ActualLen][Data...])
### 3. S7 String 格式
```csharp
// S7 String 结构
// 字节0: MaxLength (最大长度)
// 字节1: ActualLength (实际长度)
// 字节2+: ASCII字符
// 写入字符串
await _plcService.WriteAsync("DB1.DBB0", "Hello", length: 254);
// PLC中的数据: [FE] [05] [48] [65] [6C] [6C] [6F] [00] [00] ...
// ↑ ↑ H e l l o
// Max Actual
// 读取字符串
var str = await _plcService.ReadAsync<string>("DB1.DBB0");
// 结果: "Hello"
```
### 4. Bool类型的位操作
```csharp
// 西门子PLC的Bool使用位寻址
var tag = new PlcTag
{
Address = "M10.5", // 标志字节10的第5位
DataType = typeof(bool)
};
// BigEndianConverter 会正确处理位偏移
// 读取: 提取字节10的第5位
// 写入: 只修改字节10的第5位,保持其他位不变
```
---
## 📊 性能调优
### 1. PDU大小优化
```csharp
// S7-1200/1500 支持更大的PDU
services.AddSiemensPlc(options =>
{
options.PduSize = 960; // 最大化单次传输
});
// 单次可读取更多数据,减少网络往返
```
### 2. MaxGapBytes 调整
```csharp
// 场景1: 地址密集分布
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 50; // 增大合并范围
});
// 场景2: 地址稀疏分布
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 10; // 减少无效数据传输
});
```
### 3. 标签地址规划
```csharp
// ✅ 好的实践: 连续地址
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", ... },
new PlcTag { Address = "DB1.DBW2", ... },
new PlcTag { Address = "DB1.DBW4", ... }
};
// 优化为1次读取
// ❌ 避免: 分散地址
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", ... },
new PlcTag { Address = "DB5.DBW0", ... },
new PlcTag { Address = "DB10.DBW0", ... }
};
// 需要3次读取
```
---
## 🔍 故障排查
### 常见问题
#### 1. 连接失败
```
错误: Connection failed: A connection attempt failed because the connected party did not properly respond...
```
**解决方案:**
- ✅ 检查PLC IP地址是否正确
- ✅ 检查网络连通性 (`ping 192.168.1.100`)
- ✅ 确认PLC的 **PUT/GET** 权限已启用
- ✅ 检查防火墙规则(端口102)
- ✅ 验证 Rack/Slot 配置
#### 2. PDU大小错误
```
错误: Read failed: The PDU size is too large
```
**解决方案:**
```csharp
// 根据PLC型号调整PDU
options.PduSize = 240; // S7-300使用较小值
```
#### 3. 地址格式错误
```
错误: 不支持或无效的地址格式: DB1.0
```
**解决方案:**
```csharp
// ❌ 错误格式
"DB1.0" // 缺少数据类型
"DB1DBW0" // 缺少点分隔符
// ✅ 正确格式
"DB1.DBW0" // 数据块1, 字0
"DB1.DBX0.0" // 数据块1, 字节0, 位0
```
#### 4. 权限问题
```
错误: Access denied or protection active
```
**解决方案:**
- 在TIA Portal中检查 **保护级别**
- 启用 **PUT/GET 通信**
- 确认 DB块的 **访问权限** 未设置为 **优化访问**
### 调试技巧
#### 启用详细日志
```csharp
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Trace); // 查看所有日志
});
// 日志输出示例
[TRACE] Parsed address DB1.DBW0 for tag temp_001
[DEBUG] Optimizing 10 tags into 3 batches
[TRACE] Created new batch starting at DB1.DBW0
[TRACE] Merged tag temp_002 into batch (gap=2, newLength=10)
[INFO] Optimization complete: 10 tags -> 3 batches
```
#### 使用Wireshark抓包
```bash
# 过滤S7通信
tcp.port == 102
# 查看S7协议细节
s7comm
```
---
## 🧪 测试
### 模拟PLC测试
```csharp
using Moq;
using Plc.Abstractions;
using Plc.Siemens;
[Fact]
public async Task SiemensDriver_ReadBytes_Success()
{
// Arrange
var options = new SiemensPlcOptions
{
IpAddress = "192.168.1.100",
CpuType = CpuType.S71200
};
var driver = new SiemensDriver(options);
// 注意: 需要真实的PLC或模拟器
await driver.ConnectAsync();
// Act
var result = await driver.ReadBytesAsync("DB1.DBW0", 2);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Length);
await driver.DisconnectAsync();
}
```
### 地址解析测试
```csharp
[Theory]
[InlineData("DB1.DBW0", 0x84, 1, 0, 0)]
[InlineData("DB1.DBX0.5", 0x84, 1, 0, 5)]
[InlineData("M100.3", 0x83, 0, 100, 3)]
public void SiemensAddressParser_Parse_ValidAddress(
string address,
byte expectedArea,
int expectedDbNumber,
int expectedByteOffset,
int expectedBitOffset)
{
// Act
var result = SiemensAddressParser.Parse(address);
// Assert
Assert.Equal((SiemensMemoryArea)expectedArea, result.Area);
Assert.Equal(expectedDbNumber, result.DbNumber);
Assert.Equal(expectedByteOffset, result.ByteOffset);
Assert.Equal(expectedBitOffset, result.BitOffset);
}
```
---
## 📚 参考资料
### 官方文档
- [TIA Portal 文档](https://support.industry.siemens.com/cs/document/109742691)
- [S7 通信协议](https://support.industry.siemens.com/cs/document/26483647)
- [S7.Net Plus GitHub](https://github.com/S7NetPlus/s7netplus)
### 相关资源
- **S7 地址格式**: [Siemens 地址规范](https://support.industry.siemens.com/cs/document/67785739)
- **CPU 规格**: [S7-1200 系统手册](https://support.industry.siemens.com/cs/document/36932465)
- **通信设置**: [S7 Open User Communication](https://support.industry.siemens.com/cs/document/26483647)
---
## 🔐 安全注意事项
1. **网络隔离** - PLC应在独立的工业网络中
2. **访问控制** - 限制对PLC IP的访问
3. **保护级别** - 在TIA Portal中设置适当的保护级别
4. **审计日志** - 记录所有PLC写入操作
5. **变更管理** - 写入操作应经过审批流程
```csharp
// 建议:添加写入权限检查
public class SecurePlcService : IPlcService
{
private readonly IPlcService _inner;
private readonly IAuthorizationService _auth;
public async Task<Result> WriteAsync<T>(string address, T value)
{
if (!await _auth.AuthorizeAsync("PlcWrite"))
{
return Result.Failure("Unauthorized");
}
_logger.LogWarning("User {User} writing to {Address}",
_currentUser.Name, address);
return await _inner.WriteAsync(address, value);
}
}
```
---
## 📈 版本历史
### v1.0.0 (2025-01-09)
- ✅ 初始发布
- ✅ 支持 S7-200/300/400/1200/1500
- ✅ 完整的地址解析
- ✅ 批量优化
- ✅ 配置验证
- ✅ 日志集成
- ✅ 自动重连
---
+138
View File
@@ -0,0 +1,138 @@
using Common.Results;
using Plc.Abstractions;
using Plc.Siemens.Models;
using Plc.Siemens.Options;
using Plc.Siemens.Utils;
using S7.Net;
namespace Plc.Siemens;
public class SiemensDriver : IPlcDriver
{
private readonly SiemensPlcOptions _options;
private S7.Net.Plc? _plc;
public SiemensDriver(SiemensPlcOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool IsConnected => _plc?.IsConnected ?? false;
public int MaxPduSize => _options.PduSize;
public async Task<Result> ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
// Close existing connection if any
if (_plc != null)
{
_plc.Close();
_plc = null;
}
// Create new instance with latest options
_plc = new S7.Net.Plc(
_options.CpuType,
_options.IpAddress,
_options.Port,
_options.Rack,
_options.Slot);
await _plc.OpenAsync(cancellationToken);
if (_plc.IsConnected)
return Result.Success();
return Result.Failure("Failed to connect to PLC (IsConnected is false).");
}
catch (Exception ex)
{
return Result.Failure($"Connection failed: {ex.Message}");
}
}
public Task<Result> DisconnectAsync()
{
try
{
_plc?.Close();
return Task.FromResult(Result.Success());
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure($"Disconnect failed: {ex.Message}"));
}
}
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
{
if (!IsConnected || _plc == null)
return Result.Failure<byte[]>("PLC is not connected");
try
{
var siemensAddr = SiemensAddressParser.Parse(address);
var dataType = MapToS7DataType(siemensAddr.Area);
var bytes = await _plc.ReadBytesAsync(
dataType,
siemensAddr.DbNumber,
siemensAddr.ByteOffset,
length,
cancellationToken);
return Result.Success(bytes);
}
catch (Exception ex)
{
return Result.Failure<byte[]>($"Read failed: {ex.Message}");
}
}
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
{
if (!IsConnected || _plc == null)
return Result.Failure("PLC is not connected");
try
{
var siemensAddr = SiemensAddressParser.Parse(address);
var dataType = MapToS7DataType(siemensAddr.Area);
await _plc.WriteBytesAsync(
dataType,
siemensAddr.DbNumber,
siemensAddr.ByteOffset,
data,
cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Write failed: {ex.Message}");
}
}
private DataType MapToS7DataType(SiemensMemoryArea area)
{
return area switch
{
SiemensMemoryArea.DataBlock => DataType.DataBlock,
SiemensMemoryArea.Memory => DataType.Memory,
SiemensMemoryArea.Inputs => DataType.Input,
SiemensMemoryArea.Outputs => DataType.Output,
SiemensMemoryArea.Timer => DataType.Timer,
SiemensMemoryArea.Counter => DataType.Counter,
_ => throw new ArgumentOutOfRangeException(nameof(area), area, $"Unsupported memory area: {area}")
};
}
public void Dispose()
{
_plc?.Close();
_plc = null;
}
}
+135
View File
@@ -0,0 +1,135 @@
using Plc.Abstractions;
using Plc.Models;
using Plc.Siemens.Models;
using Plc.Siemens.Utils;
using Plc.Utils;
using Microsoft.Extensions.Logging;
namespace Plc.Siemens;
public class SiemensOptimizer : IPlcOptimizer
{
// 允许变量之间合并为一次读取的最大间隔字节数
private const int DefaultMaxGapBytes = 20;
private readonly int _maxGapBytes;
private readonly ILogger<SiemensOptimizer>? _logger;
public SiemensOptimizer(int maxGapBytes = DefaultMaxGapBytes, ILogger<SiemensOptimizer>? logger = null)
{
_maxGapBytes = maxGapBytes;
_logger = logger;
}
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
var batches = new List<PlcReadBatch>();
// 1. 准备标签(解析地址)
var validTags = new List<PlcTag>();
foreach (var tag in tags)
{
if (tag.ParsedAddress == null)
{
try
{
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
_logger?.LogTrace("Parsed address {Address} for tag {TagId}", tag.Address, tag.Id);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to parse address {Address} for tag {TagId}, skipping",
tag.Address, tag.Id);
continue;
}
}
validTags.Add(tag);
}
_logger?.LogDebug("Optimizing {ValidCount} valid tags out of {TotalCount} tags",
validTags.Count, tags.Count());
// 2. 按区域和 DB 号分组(物理位置)
var groups = validTags.GroupBy(t =>
{
var addr = (SiemensAddress)t.ParsedAddress!;
return new { addr.Area, addr.DbNumber };
});
foreach (var group in groups)
{
// 3. 按字节偏移量排序
var sortedTags = group.OrderBy(t => ((SiemensAddress)t.ParsedAddress!).ByteOffset).ToList();
_logger?.LogTrace("Processing group: Area={Area}, DB={DbNumber}, Tags={Count}",
group.Key.Area, group.Key.DbNumber, sortedTags.Count);
PlcReadBatch? currentBatch = null;
foreach (var tag in sortedTags)
{
var addr = (SiemensAddress)tag.ParsedAddress!;
int typeSize = tag.DataType == typeof(string)
? TypeHelper.GetSize(tag.DataType, tag.Length)
: TypeHelper.GetSize(tag.DataType);
int byteLength = typeSize * tag.Length; // 此标签的总字节数
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
if (currentBatch == null)
{
currentBatch = CreateBatch(tag, addr, byteLength);
batches.Add(currentBatch);
_logger?.LogTrace("Created new batch starting at {Address}", tag.Address);
continue;
}
var batchStartAddr = (SiemensAddress)currentBatch.ParsedStartAddress!;
int currentBatchEnd = batchStartAddr.ByteOffset + currentBatch.TotalLength;
// 计算间隔 (Gap)
int gap = addr.ByteOffset - currentBatchEnd;
// 计算潜在的新长度
// 新标签地址 + 其长度 - 批次起始位置
int newTotalLength = (addr.ByteOffset + byteLength) - batchStartAddr.ByteOffset;
// 合并条件:
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
// 2. 间隔足够小
// 3. 总大小适合 PDU
if (gap >= 0 && gap <= _maxGapBytes && newTotalLength <= maxPduSize)
{
currentBatch.Tags.Add(tag);
currentBatch.TotalLength = newTotalLength;
_logger?.LogTrace("Merged tag {TagId} into batch (gap={Gap}, newLength={Length})",
tag.Id, gap, newTotalLength);
}
else
{
// 开启新批次
_logger?.LogTrace("Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
tag.Id, gap, newTotalLength, maxPduSize);
currentBatch = CreateBatch(tag, addr, byteLength);
batches.Add(currentBatch);
}
}
}
_logger?.LogDebug("Optimization complete: {TagCount} tags -> {BatchCount} batches",
validTags.Count, batches.Count);
return batches;
}
private PlcReadBatch CreateBatch(PlcTag tag, SiemensAddress addr, int byteLength)
{
return new PlcReadBatch
{
StartAddress = tag.Address,
ParsedStartAddress = addr,
TotalLength = byteLength,
Tags = new List<PlcTag> { tag }
};
}
}
@@ -0,0 +1,100 @@
using Plc.Siemens.Models;
using System.Text.RegularExpressions;
namespace Plc.Siemens.Utils;
public static class SiemensAddressParser
{
// DB匹配: DB1.DBB0, DB1.DBW2, DB1.DBD4, DB1.DBX0.0
// Group 1: DbNumber
// Group 2: Type (B,W,D,X) - Ignored
// Group 3: ByteOffset
// Group 5: BitOffset (Optional)
private static readonly Regex DbRegex = new(@"^DB(\d+)\.DB[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// M匹配: M100, MB100, MW100, MD100, MX100.0, M100.1
// Group 1: ByteOffset
// Group 3: BitOffset (Optional)
private static readonly Regex MRegex = new(@"^M[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// I匹配: I0.0, IB0, IW0, ID0
private static readonly Regex IRegex = new(@"^I[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// Q匹配: Q0.0, QB0, QW0, QD0
private static readonly Regex QRegex = new(@"^Q[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// V区域匹配: V100, V100.0 (S7-200 Smart特定,映射到DB1)
private static readonly Regex VRegex = new(@"^V(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
public static SiemensAddress Parse(string address)
{
address = address.Trim().ToUpper();
// 0. Try V Area (S7-200 Smart: V100 -> DB1.DBB100)
var vMatch = VRegex.Match(address);
if (vMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.DataBlock,
DbNumber = 1, // V区域映射到DB1
ByteOffset = int.Parse(vMatch.Groups[1].Value),
BitOffset = vMatch.Groups[3].Success ? int.Parse(vMatch.Groups[3].Value) : 0
};
}
// 1. Try DB
var dbMatch = DbRegex.Match(address);
if (dbMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.DataBlock,
DbNumber = int.Parse(dbMatch.Groups[1].Value),
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
};
}
// 2. Try M (Memory/Flags)
var mMatch = MRegex.Match(address);
if (mMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Memory,
DbNumber = 0,
ByteOffset = int.Parse(mMatch.Groups[1].Value),
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
};
}
// 3. Try I (Inputs)
var iMatch = IRegex.Match(address);
if (iMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Inputs,
DbNumber = 0,
ByteOffset = int.Parse(iMatch.Groups[1].Value),
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
};
}
// 4. Try Q (Outputs)
var qMatch = QRegex.Match(address);
if (qMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Outputs,
DbNumber = 0,
ByteOffset = int.Parse(qMatch.Groups[1].Value),
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
};
}
throw new ArgumentException($"不支持或无效的地址格式: {address}");
}
}
@@ -0,0 +1,23 @@
namespace Plc.Abstractions;
public interface IDataConverter
{
/// <summary>
/// 将原始字节(从指定偏移量开始)转换为目标类型
/// </summary>
/// <param name="buffer">字节缓冲区</param>
/// <param name="offset">起始偏移量</param>
/// <param name="type">目标类型</param>
/// <param name="length">数据长度(对字符串和数组有效)</param>
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0);
/// <summary>
/// 将值转换为字节
/// </summary>
/// <param name="value">要转换的值</param>
/// <param name="type">值的类型</param>
/// <param name="length">数据长度(对字符串和数组有效)</param>
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
byte[] ToBytes(object value, Type type, int length, int bitOffset = 0);
}
+22
View File
@@ -0,0 +1,22 @@
namespace Plc.Abstractions;
/// <summary>
/// 定义PLC地址的通用接口,用于不同品牌的地址解析结果
/// </summary>
public interface IPlcAddress
{
/// <summary>
/// 获取字节偏移量
/// </summary>
int ByteOffset { get; }
/// <summary>
/// 获取位偏移量(仅对bool类型有效)
/// </summary>
int BitOffset { get; }
/// <summary>
/// 获取地址的字符串表示
/// </summary>
string ToString();
}
+45
View File
@@ -0,0 +1,45 @@
using Common.Results;
namespace Plc.Abstractions;
/// <summary>
/// 定义 PLC 驱动的底层契约。
/// 此接口的实现处理具体的协议细节(例如:S7, Modbus)。
/// </summary>
public interface IPlcDriver : IDisposable
{
/// <summary>
/// 获取一个值,指示驱动程序当前是否已连接到 PLC。
/// </summary>
bool IsConnected { get; }
/// <summary>
/// 获取连接支持的最大 PDU(协议数据单元)大小。
/// 优化器使用此值来确定最大批量大小。
/// </summary>
int MaxPduSize { get; }
/// <summary>
/// 建立与 PLC 的连接。
/// </summary>
Task<Result> ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 关闭与 PLC 的连接。
/// </summary>
Task<Result> DisconnectAsync();
/// <summary>
/// 从指定地址读取原始字节序列。
/// </summary>
/// <param name="address">起始地址字符串(例如 "DB1.0")。</param>
/// <param name="length">要读取的字节数。</param>
Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default);
/// <summary>
/// 将原始字节序列写入指定地址。
/// </summary>
/// <param name="address">起始地址字符串。</param>
/// <param name="data">要写入的字节。</param>
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,44 @@
using Plc.Models;
namespace Plc.Abstractions;
/// <summary>
/// 负责分析请求的标签列表,并将它们分组为优化的批量请求。
/// </summary>
public interface IPlcOptimizer
{
/// <summary>
/// 基于地址位置和 PDU 大小,将单个标签分组为高效的读取批次。
/// </summary>
/// <param name="tags">活动订阅列表。</param>
/// <param name="maxPduSize">单次请求中可读取的最大字节数。</param>
/// <returns>批量请求列表。</returns>
List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize);
}
/// <summary>
/// 表示覆盖多个标签的单个优化读取操作。
/// </summary>
public class PlcReadBatch
{
/// <summary>
/// 此批量读取的起始地址。
/// </summary>
public string StartAddress { get; set; } = string.Empty;
/// <summary>
/// 要读取的总字节数。
/// </summary>
public int TotalLength { get; set; }
/// <summary>
/// 包含在此批量范围内的标签列表。
/// </summary>
public List<PlcTag> Tags { get; set; } = new();
/// <summary>
/// 驱动程序特定的起始地址上下文(避免重新解析)。
/// 使用 IPlcAddress 接口以支持类型安全访问。
/// </summary>
public IPlcAddress? ParsedStartAddress { get; set; }
}
+41
View File
@@ -0,0 +1,41 @@
using Common.Results;
using Plc.Models;
namespace Plc.Abstractions;
/// <summary>
/// 用于与 PLC 交互的高层服务。
/// 处理连接管理、批量读取和数据转换。
/// </summary>
public interface IPlcService
{
/// <summary>
/// 启动后台监控循环。
/// </summary>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 停止后台监控循环。
/// </summary>
Task StopAsync();
/// <summary>
/// 对单个地址执行立即、临时的读取。
/// </summary>
Task<Result<T>> ReadAsync<T>(string address);
/// <summary>
/// 对单个地址执行立即、临时的写入。
/// </summary>
Task<Result> WriteAsync<T>(string address, T value);
/// <summary>
/// 订阅一个标签到监控循环。
/// </summary>
void Subscribe(PlcTag tag);
/// <summary>
/// 从监控循环取消订阅标签。
/// </summary>
void Unsubscribe(string tagId);
}
@@ -0,0 +1,14 @@
namespace Plc.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public class PlcAddressAttribute : Attribute
{
public string Address { get; }
public int Length { get; }
public PlcAddressAttribute(string address, int length = 1)
{
Address = address;
Length = length;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Plc.Models;
public enum PlcBrand
{
Siemens,
Omron, // 预留
Mitsubishi, // 预留
ModbusTcp // 预留
}
+47
View File
@@ -0,0 +1,47 @@
using Plc.Abstractions;
namespace Plc.Models;
/// <summary>
/// 表示用户想要监控或访问的 PLC 中的特定数据点。
/// </summary>
public class PlcTag
{
/// <summary>
/// 此标签订阅的唯一标识符。
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// PLC 地址字符串(例如 "DB1.DBW0", "M100.1")。
/// </summary>
public required string Address { get; set; }
/// <summary>
/// 此数据表示的 .NET 类型(例如 typeof(int), typeof(float))。
/// </summary>
public required Type DataType { get; set; }
/// <summary>
/// 数据长度(主要用于字符串或数组)。默认为 1。
/// </summary>
public int Length { get; set; } = 1;
/// <summary>
/// 此标签的最后已知值。
/// </summary>
public object? Value { get; set; }
/// <summary>
/// 当值更改(或刷新)时调用的操作。
/// 回调接收 Tag 实例和新值。
/// </summary>
public Action<PlcTag, object?>? OnChanged { get; set; }
/// <summary>
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
/// 这避免了在每个周期重新解析地址字符串。
/// 使用 IPlcAddress 接口以支持类型安全访问。
/// </summary>
public IPlcAddress? ParsedAddress { get; set; }
}
@@ -0,0 +1,9 @@
namespace Plc.Options;
public class PlcMonitorOptions
{
/// <summary>
/// 监控循环刷新率 (毫秒)
/// </summary>
public TimeSpan RefreshRate { get; set; } = TimeSpan.FromMilliseconds(200);
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
</ItemGroup>
</Project>
+106
View File
@@ -0,0 +1,106 @@
using System.ComponentModel;
using System.Reflection;
using Plc.Abstractions;
using Plc.Attributes;
using Plc.Models;
namespace Plc.Proxy;
public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
{
private IPlcService _plcService = null!;
private readonly Dictionary<string, PlcTag> _tags = new();
// 关键修改:使用私有字段手动管理委托链,避免递归调用
private PropertyChangedEventHandler? _propertyChangedInternal;
// 声明为 virtual event 只是为了骗过编译器的 TypeLoadException,
// 但我们在 Invoke 里不操作它,而是操作 _propertyChangedInternal。
// 实际上,对于 DispatchProxy 来说,我们并不需要这个 event 真的工作,
// 只需要它存在以满足接口签名,并允许被重写。
public virtual event PropertyChangedEventHandler? PropertyChanged
{
add => _propertyChangedInternal += value;
remove => _propertyChangedInternal -= value;
}
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod == null) return null;
// 1. Handle Property Getters (get_PropName)
if (targetMethod.Name.StartsWith("get_"))
{
var propName = targetMethod.Name.Substring(4);
if (_tags.TryGetValue(propName, out var tag))
{
return tag.Value; // Return cached value
}
}
// 2. Handle Property Setters (set_PropName)
if (targetMethod.Name.StartsWith("set_"))
{
var propName = targetMethod.Name.Substring(4);
if (_tags.TryGetValue(propName, out var tag) && args != null && args.Length > 0)
{
var newValue = args[0];
_plcService.WriteAsync(tag.Address, newValue).GetAwaiter().GetResult();
return null;
}
}
// 3. Handle INotifyPropertyChanged methods (add_PropertyChanged, remove_PropertyChanged)
// 这里是关键:我们拦截对 add_PropertyChanged 的调用,
// 将其重定向到我们的私有字段 _propertyChangedInternal,
// 从而避免调用 this.PropertyChanged += ... 导致的递归。
if (targetMethod.Name == "add_PropertyChanged" && args != null && args.Length == 1)
{
var handler = (PropertyChangedEventHandler)args[0]!;
_propertyChangedInternal += handler;
return null;
}
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
{
var handler = (PropertyChangedEventHandler)args[0]!;
_propertyChangedInternal -= handler;
return null;
}
return null;
}
public static T Create<T>(IPlcService plcService) where T : class, INotifyPropertyChanged
{
object proxy = Create<T, PlcInterfaceProxy>();
((PlcInterfaceProxy)proxy).Initialize(typeof(T), plcService);
return (T)proxy;
}
private void Initialize(Type interfaceType, IPlcService plcService)
{
_plcService = plcService;
foreach (var prop in interfaceType.GetProperties())
{
var attr = prop.GetCustomAttribute<PlcAddressAttribute>();
if (attr != null)
{
var tag = new PlcTag
{
Address = attr.Address,
DataType = prop.PropertyType,
Length = attr.Length,
OnChanged = (t, v) =>
{
// 触发时,调用私有字段
_propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(prop.Name));
}
};
_tags[prop.Name] = tag;
_plcService.Subscribe(tag);
}
}
}
}
+730
View File
@@ -0,0 +1,730 @@
# Plc - 工业PLC通信核心库
[![.NET](https://img.shields.io/badge/.NET-8.0-blue)](https://dotnet.microsoft.com/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
## 📖 概述
Plc 是一个通用的工业PLC通信抽象层库,提供了统一的接口来与不同品牌的PLC设备进行通信。该库采用分层架构设计,将协议细节抽象化,使得上层应用无需关心具体的PLC品牌和协议实现。
### 核心特性
- ✅ **品牌无关** - 抽象接口设计,支持多种PLC品牌(西门子、欧姆龙、三菱等)
- ✅ **高性能优化** - 智能批量读取优化,减少网络往返次数
- ✅ **实时监控** - 支持标签订阅模式,自动轮询和变更通知
- ✅ **类型安全** - 强类型数据转换,支持多种基础数据类型
- ✅ **连接管理** - 自动重连、指数退避策略
- ✅ **完整日志** - 集成 Microsoft.Extensions.Logging
- ✅ **依赖注入** - 原生支持 .NET DI 容器
---
## 🏗️ 架构设计
### 分层架构
```
┌─────────────────────────────────────┐
│ 应用层 (Application Layer) │
│ - ViewModel/Controller │
│ - Business Logic │
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 服务层 (Service Layer) │
│ - PlcMonitorService │ ← 核心服务
│ - 订阅管理、批量优化 │
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 抽象层 (Abstraction Layer) │
│ - IPlcDriver │ ← 驱动接口
│ - IPlcOptimizer │ ← 优化器接口
│ - IDataConverter │ ← 转换器接口
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 实现层 (Implementation Layer) │
│ - SiemensDriver │ ← 品牌驱动
│ - OmronDriver │
│ - ModbusDriver │
└─────────────────────────────────────┘
```
### 核心组件
#### 1. **IPlcService** - PLC服务接口
提供统一的PLC通信入口,包括:
- 连接管理(`StartAsync`, `StopAsync`)
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
- 订阅监控(`Subscribe`, `Unsubscribe`)
#### 2. **IPlcDriver** - PLC驱动接口
定义底层通信协议的契约:
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
- 状态查询(`IsConnected`, `MaxPduSize`)
#### 3. **IPlcOptimizer** - 批量优化器接口
负责优化多标签读取策略:
- 地址排序和分组
- 间隙合并(减少通信次数)
- PDU大小限制
#### 4. **IDataConverter** - 数据转换器接口
处理PLC字节序和.NET类型转换:
- 大端/小端转换
- 基础类型(bool, byte, short, int, float, double, string)
- 位偏移支持(bool类型)
---
## 🚀 快速开始
### 安装
```bash
# 安装核心库
dotnet add package YourCompany.Plc
# 安装西门子驱动
dotnet add package YourCompany.Plc.Siemens
```
### 基本使用
#### 1. 注册服务
```csharp
using Plc.Siemens.DependencyInjection;
// 在 Program.cs 或 Startup.cs 中
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.PduSize = 960;
});
```
#### 2. 使用服务
```csharp
public class MyController
{
private readonly IPlcService _plcService;
public MyController(IPlcService plcService)
{
_plcService = plcService;
}
public async Task Example()
{
// 启动PLC服务
await _plcService.StartAsync();
// 读取数据
var result = await _plcService.ReadAsync<short>("DB1.DBW0");
if (result.IsSuccess)
{
Console.WriteLine($"Value: {result.Value}");
}
// 写入数据
await _plcService.WriteAsync("DB1.DBW0", (short)1234);
// 停止服务
await _plcService.StopAsync();
}
}
```
---
## 📚 核心概念
### PlcTag - 标签模型
`PlcTag` 表示PLC中的一个数据点:
```csharp
var tag = new PlcTag
{
Id = "temperature_sensor",
Address = "DB1.DBD0",
DataType = typeof(float),
Length = 1,
OnChanged = (tag, newValue) =>
{
Console.WriteLine($"温度变化: {newValue}°C");
}
};
_plcService.Subscribe(tag);
```
**属性说明:**
- `Id` - 唯一标识符
- `Address` - PLC地址(格式取决于驱动)
- `DataType` - .NET 类型
- `Length` - 数组长度(字符串的最大长度)
- `OnChanged` - 值变化回调
- `Value` - 当前值(只读)
### 支持的数据类型
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|---------|---------|--------|------|
| `bool` | BOOL | 1 | 支持位偏移 |
| `byte` | BYTE | 1 | 无符号8位 |
| `short` | INT | 2 | 有符号16位 |
| `ushort` | UINT | 2 | 无符号16位 |
| `int` | DINT | 4 | 有符号32位 |
| `uint` | UDINT | 4 | 无符号32位 |
| `float` | REAL | 4 | 单精度浮点 |
| `double` | LREAL | 8 | 双精度浮点 |
| `string` | STRING | 2+N | S7格式字符串 |
---
## ⚙️ 核心通信优化机制
本项目采用了**分桶(Batching/Bucketing)**优化策略,这是高性能 PLC 通信的关键。
### 1. 为什么需要优化?
PLC 通信通常基于 TCP/IP 协议,每次读写请求都包含网络握手、协议头封装和 PLC 内部处理时间。如果用户订阅了 100 个离散点位,如果发送 100 次网络请求,延迟将极高(例如 100 * 20ms = 2000ms),无法满足实时监控需求。
### 2. 优化原理
系统通过 `IPlcOptimizer`(如 `SiemensOptimizer`)在内存中动态重组读写请求,将多个小的、地址相邻的请求合并为一个大的网络请求。
#### 处理流程(伪代码)
```mermaid
graph LR
A[原始订阅Tags] --> B[1. 分组 Grouping]
B --> C[2. 排序 Sorting]
C --> D[3. 合并 Merging]
D --> E[执行网络请求]
```
1. **分组 (Grouping)**: 根据内存区域(如 DB1, DB2, M区, I区)将 Tag 分组。不同区域无法合并。
2. **排序 (Sorting)**: 在同一组内,按字节偏移量(Byte Offset)从小到大排序。
3. **合并 (Merging)**:
* 遍历排序后的 Tags。
* 计算两个 Tag 之间的**间隙 (Gap)**。
* 如果 `Gap <= MaxGapBytes` (默认20字节) 且 `TotalLength <= MaxPduSize` (最大包长),则合并。
* 否则,创建一个新的**批次 (Batch)**。
### 3. 实际案例分析
假设用户订阅了以下点位(西门子 S7):
- Tag A: `DB1.DBW0` (2 bytes)
- Tag B: `DB1.DBW4` (2 bytes)
- Tag C: `DB1.DBW1000` (2 bytes)
**处理逻辑:**
1. **Tag A 和 Tag B**:
* Tag A 结束于 byte 2,Tag B 开始于 byte 4。
* 间隙 Gap = 4 - 2 = 2 bytes。
* 2 bytes < 20 bytes (阈值),且总长未超 PDU。
* **结果**: 合并为一个请求:`Start: 0, Length: 6`。虽然读回了无用的 byte 2-3,但节省了一次网络请求。
2. **Tag B 和 Tag C**:
* Tag B 结束于 byte 6,Tag C 开始于 byte 1000。
* 间隙 Gap = 994 bytes。
* 994 bytes > 20 bytes。
* **结果**: 断开,Tag C 放入新的批次。
**最终网络请求**: 2 次(而不是 3 次)。
### 4. 极限情况处理
* **只订阅 1 个点位**: 优化器生成 1 个批次,包含 1 个 Tag。无额外开销。
* **连续订阅 1000 个点位 (例如 DB1.DBW0 - DB1.DBW2000)**:
* 总长度 2000 字节。
* 假设 PLC PDU 限制为 960 字节。
* 优化器会自动切分为 3 个批次:
* Batch 1: 0 - 960
* Batch 2: 960 - 1920
* Batch 3: 1920 - 2000
* 系统自动处理分包,上层业务无感。
### 5. 未来扩展性 (适配欧姆龙/三菱)
本架构设计之初就考虑了多品牌支持。适配新品牌只需实现以下接口,**无需修改 Client/ViewModel 代码**:
1. **实现 `IPlcDriver`**: 处理底层的 Socket/串口 通信(如 FINS, MC 协议)。
2. **实现 `IPlcAddress`**: 定义新品牌的地址格式(如欧姆龙的 `D100`, `W0.00`)。
3. **实现 `IPlcOptimizer`**: 针对新协议调整优化参数。
#### 扩展示例(伪代码)
```csharp
// 欧姆龙 FINS 协议优化器示例
public class OmronOptimizer : IPlcOptimizer
{
// 欧姆龙按 Word (2byte) 寻址,且单次读取限制不同
private const int MaxWordCount = 2000;
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// 1. 按内存区 (DM, CIO, WR) 分组
// 2. 按 Word 地址排序
// 3. 合并逻辑:如果两个地址相差 < 10 个 Words,则合并读取
// 4. 返回适用于 FINS 协议的 ReadRequest 列表
}
}
```
---
## 📅 分级订阅方案 (Proposed Tiered Subscription)
> 本节描述了未来支持不同频率订阅(快/慢循环)的实现思路,目前尚未实装。
### 1. 需求背景
在复杂的工业场景中,不同数据的实时性要求不同:
- **关键控制信号**(如 启动/停止/报警):需要高频刷新(100ms)。
- **普通模拟量**(如 温度/液位):变化较慢,只需低频刷新(1000ms)。
- **配方/参数**(如 设定值):只需在变动时读取,或极低频刷新(5000ms)。
当前单一循环机制(统一刷新率)会导致资源浪费或关键数据延迟。
### 2. 接口设计变更
在订阅时增加 `Priority` 或 `Interval` 参数:
```csharp
public enum MonitorPriority
{
High, // 100ms
Normal, // 200ms (默认)
Low // 1000ms
}
public class PlcTag
{
// ... 原有属性
public MonitorPriority Priority { get; set; } = MonitorPriority.Normal;
}
// 或者在 Subscribe 时指定
_plcService.Subscribe(tag, MonitorPriority.High);
```
### 3. 实现策略
#### 方案 A:多循环线程 (Multi-Loop)
`PlcMonitorService` 内部维护多个 `MonitorLoop` 任务,每个任务对应一个优先级队列。
* **HighPriorityTask**: `While(true) { Read(HighTags); Delay(100ms); }`
* **LowPriorityTask**: `While(true) { Read(LowTags); Delay(1000ms); }`
**优点**:逻辑清晰,互不干扰。
**缺点**:如果底层驱动不支持并发(非线程安全),需要加锁,导致高优任务被低优任务阻塞。S7.Net 库通常不支持并发读写,需要加全局锁。
#### 方案 B:加权轮询 (Weighted Round-Robin)
在单个 `MonitorLoop` 中通过计数器控制读取频率。假设基准频率为 100ms。
* **Tick 1**: 读取 HighTags
* **Tick 2**: 读取 HighTags
* **Tick 3**: 读取 HighTags
* **...**
* **Tick 10**: 读取 HighTags + LowTags (每10次读取一次低优)
**伪代码:**
```csharp
long tick = 0;
while (!token.IsCancellationRequested)
{
var highTags = _subscriptions.Where(t => t.Priority == High);
var lowTags = _subscriptions.Where(t => t.Priority == Low);
var tagsToRead = new List<PlcTag>(highTags);
// 每 10 个周期 (10 * 100ms = 1s) 读取一次低频数据
if (tick % 10 == 0)
{
tagsToRead.AddRange(lowTags);
}
// 统一优化和读取
var batches = _optimizer.Optimize(tagsToRead, ...);
await ExecuteBatches(batches);
tick++;
await Task.Delay(100);
}
```
**优点**:单线程模型,无并发冲突,驱动兼容性好。
**缺点**:如果低频数据量极大,在 Tick 10 这一轮会耗时较长,可能导致该轮的高频数据延迟。可以通过将低频数据分片(Time-Slicing)分散到不同 Tick 来解决。
### 4. 配置扩展
在 `SiemensPlcOptions` 中增加各级频率配置:
```csharp
public class SiemensPlcOptions
{
public int HighInterval { get; set; } = 100;
public int NormalInterval { get; set; } = 200;
public int LowInterval { get; set; } = 1000;
}
```
---
## 🎯 高级功能
### 1. 标签订阅与监控
```csharp
// 订阅多个标签
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) },
new PlcTag { Address = "DB1.DBW2", DataType = typeof(short) },
new PlcTag { Address = "DB1.DBD4", DataType = typeof(float) }
};
foreach (var tag in tags)
{
_plcService.Subscribe(tag);
}
// 服务会自动批量优化读取
await _plcService.StartAsync();
```
### 3. 使用特性绑定
```csharp
using Plc.Attributes;
using Plc.Proxy;
public interface IPlcData : INotifyPropertyChanged
{
[PlcAddress("DB1.DBW0")]
short Temperature { get; set; }
[PlcAddress("DB1.DBW2")]
short Pressure { get; set; }
[PlcAddress("DB1.DBX4.0")]
bool AlarmFlag { get; set; }
}
// 创建代理
var plcData = PlcInterfaceProxy.Create<IPlcData>(_plcService);
// 自动订阅和双向绑定
plcData.PropertyChanged += (s, e) =>
{
Console.WriteLine($"{e.PropertyName} changed");
};
// 写入会自动发送到PLC
plcData.Temperature = 25;
```
### 4. 自定义优化器
```csharp
public class MyCustomOptimizer : IPlcOptimizer
{
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// 自定义优化逻辑
// 例如:按优先级分组、按更新频率分组等
}
}
// 注册自定义优化器
services.AddSingleton<IPlcOptimizer, MyCustomOptimizer>();
```
---
## ⚙️ 配置选项
### PlcMonitorService 配置
```csharp
public class PlcMonitorService
{
// 刷新率(默认 200ms)
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
// 最大重连次数(默认 10)
private const int MaxReconnectAttempts = 10;
}
```
可通过继承 `PlcMonitorService` 自定义这些参数。
---
## 🔍 错误处理
所有操作都返回 `Result` 或 `Result<T>` 类型:
```csharp
var result = await _plcService.ReadAsync<int>("DB1.DBD0");
if (result.IsSuccess)
{
Console.WriteLine($"读取成功: {result.Value}");
}
else
{
Console.WriteLine($"读取失败: {result.ErrorMessage}");
// 也可以获取异常详情
if (result.Exception != null)
{
_logger.LogError(result.Exception, "PLC读取异常");
}
}
```
---
## 📊 日志集成
库已集成 `Microsoft.Extensions.Logging`:
```csharp
// 配置日志级别
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Debug);
});
// 日志输出示例
[12:00:00 INF] Starting PLC monitor service
[12:00:01 DBG] Subscribed to tag temp_001 at address DB1.DBW0
[12:00:01 TRC] Optimized 10 tags into 3 batches
[12:00:02 WRN] PLC disconnected, attempting to reconnect (attempt 1/10)
```
---
## 🔧 扩展指南
### 实现新的PLC品牌驱动
#### 1. 实现 `IPlcDriver` 接口
```csharp
public class ModbusDriver : IPlcDriver
{
public bool IsConnected { get; private set; }
public int MaxPduSize => 253; // Modbus TCP 限制
public async Task<Result> ConnectAsync(CancellationToken cancellationToken)
{
// 实现 Modbus TCP 连接逻辑
}
public async Task<Result<byte[]>> ReadBytesAsync(
string address,
ushort length,
CancellationToken cancellationToken)
{
// 解析 Modbus 地址格式
// 例如: "40001" -> 寄存器地址
// 调用 Modbus 协议读取
}
// ... 其他方法
}
```
#### 2. 实现地址解析器
```csharp
public class ModbusAddress : IPlcAddress
{
public int RegisterAddress { get; set; }
public int ByteOffset { get; set; }
public int BitOffset { get; set; }
}
public static class ModbusAddressParser
{
public static ModbusAddress Parse(string address)
{
// 解析 Modbus 地址格式
// 例如: "40001" -> Holding Register 1
}
}
```
#### 3. 实现优化器
```csharp
public class ModbusOptimizer : IPlcOptimizer
{
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// Modbus 特定优化
// 连续寄存器合并读取
}
}
```
#### 4. 创建扩展方法
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddModbusPlc(
this IServiceCollection services,
Action<ModbusPlcOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<IPlcDriver, ModbusDriver>();
services.AddSingleton<IPlcOptimizer, ModbusOptimizer>();
services.AddSingleton<IDataConverter, LittleEndianConverter>();
services.AddSingleton<IPlcService, PlcMonitorService>();
return services;
}
}
```
---
## 🎨 设计原则
### 1. **依赖倒置原则 (DIP)**
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
### 2. **开闭原则 (OCP)**
- 对扩展开放:可添加新的PLC品牌驱动
- 对修改关闭:添加新品牌无需修改核心代码
### 3. **单一职责原则 (SRP)**
- **PlcMonitorService** - 只负责监控循环和订阅管理
- **IPlcDriver** - 只负责底层通信
- **IPlcOptimizer** - 只负责批量优化
- **IDataConverter** - 只负责类型转换
### 4. **接口隔离原则 (ISP)**
- 接口粒度合适,不强迫实现不需要的方法
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
---
## 📈 性能优化建议
### 1. 合理设置刷新率
```csharp
// 高频数据(100ms)
var criticalTag = new PlcTag { Address = "...", ... };
// 低频数据可以单独轮询
Task.Run(async () =>
{
while (true)
{
await _plcService.ReadAsync<int>("DB100.DBD0");
await Task.Delay(5000); // 5秒轮询一次
}
});
```
### 2. 减少订阅数量
- 只订阅真正需要监控的标签
- 一次性读取的数据使用 `ReadAsync`
### 3. 调整 MaxGapBytes
```csharp
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 50; // 增大间隙合并阈值
});
```
---
## 🧪 测试
### 单元测试示例
```csharp
[Fact]
public async Task ReadAsync_ValidAddress_ReturnsSuccess()
{
// Arrange
var mockDriver = new Mock<IPlcDriver>();
mockDriver.Setup(d => d.ReadBytesAsync(It.IsAny<string>(), It.IsAny<ushort>(), default))
.ReturnsAsync(Result.Success(new byte[] { 0x00, 0x64 })); // 100
var service = new PlcMonitorService(
mockDriver.Object,
Mock.Of<IPlcOptimizer>(),
new BigEndianConverter());
// Act
var result = await service.ReadAsync<short>("DB1.DBW0");
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(100, result.Value);
}
```
---
## 📝 常见问题
### Q: 如何处理连接断开?
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
### Q: 是否支持异步属性?
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
### Q: 如何优化大量标签的性能?
A:
1. 确保标签地址连续或接近
2. 调整 `MaxGapBytes` 参数
3. 增大 `PduSize`(如果PLC支持)
4. 考虑分组订阅(按更新频率)
### Q: 支持哪些PLC品牌?
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
---
## 📄 许可证
MIT License - 详见 [LICENSE](LICENSE) 文件
---
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
### 贡献指南
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
---
## 📞 联系方式
- 项目主页: [GitHub](https://github.com/yourcompany/plc)
- 问题反馈: [Issues](https://github.com/yourcompany/plc/issues)
- 文档: [Wiki](https://github.com/yourcompany/plc/wiki)
@@ -0,0 +1,198 @@
using System.Buffers.Binary;
using Plc.Abstractions;
namespace Plc.Services;
public class BigEndianConverter : IDataConverter
{
public object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0)
{
// 参数验证
if (buffer == null)
throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || offset >= buffer.Length)
throw new ArgumentOutOfRangeException(nameof(offset), "Offset is out of buffer range");
// Bool类型处理(按位)
if (type == typeof(bool))
{
if (offset >= buffer.Length)
throw new ArgumentException("Buffer too small for bool");
if (bitOffset < 0 || bitOffset > 7)
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
byte b = buffer[offset];
return (b & (1 << bitOffset)) != 0;
}
// Byte类型
if (type == typeof(byte))
{
if (offset >= buffer.Length)
throw new ArgumentException("Buffer too small for byte");
return buffer[offset];
}
// Short类型
if (type == typeof(short))
{
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for short");
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
}
// UShort类型
if (type == typeof(ushort))
{
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for ushort");
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
}
// Int类型
if (type == typeof(int))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for int");
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
}
// UInt类型
if (type == typeof(uint))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for uint");
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
}
// Float类型
if (type == typeof(float))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for float");
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
}
// Double类型
if (type == typeof(double))
{
if (offset + 8 > buffer.Length)
throw new ArgumentException("Buffer too small for double");
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
}
// String类型(S7 String格式)
if (type == typeof(string))
{
// S7 String结构:
// 字节0: MaxLength
// 字节1: ActualLength
// 字节2+: 字符数据
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for S7 string header");
int maxLen = buffer[offset];
int actualLen = buffer[offset + 1];
if (actualLen > maxLen)
actualLen = maxLen; // 安全处理
if (offset + 2 + actualLen > buffer.Length)
throw new ArgumentException($"Buffer too small for S7 string data (needs {actualLen} bytes)");
return System.Text.Encoding.ASCII.GetString(buffer, offset + 2, actualLen);
}
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
}
public byte[] ToBytes(object value, Type type, int length, int bitOffset = 0)
{
if (value == null)
throw new ArgumentNullException(nameof(value));
// Bool类型(按位)
if (type == typeof(bool))
{
if (bitOffset < 0 || bitOffset > 7)
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
byte b = 0;
if ((bool)value)
b = (byte)(1 << bitOffset);
return new[] { b };
}
// Byte类型
if (type == typeof(byte))
return new[] { (byte)value };
// Short类型
if (type == typeof(short))
{
var bytes = new byte[2];
BinaryPrimitives.WriteInt16BigEndian(bytes, (short)value);
return bytes;
}
// UShort类型
if (type == typeof(ushort))
{
var bytes = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(bytes, (ushort)value);
return bytes;
}
// Int类型
if (type == typeof(int))
{
var bytes = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(bytes, (int)value);
return bytes;
}
// UInt类型
if (type == typeof(uint))
{
var bytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(bytes, (uint)value);
return bytes;
}
// Float类型
if (type == typeof(float))
{
var bytes = new byte[4];
var intVal = BitConverter.SingleToInt32Bits((float)value);
BinaryPrimitives.WriteInt32BigEndian(bytes, intVal);
return bytes;
}
// Double类型
if (type == typeof(double))
{
var bytes = new byte[8];
var longVal = BitConverter.DoubleToInt64Bits((double)value);
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
return bytes;
}
// String类型(S7 String格式)
if (type == typeof(string))
{
var str = (string)value;
var strBytes = System.Text.Encoding.ASCII.GetBytes(str);
var actualLen = Math.Min(strBytes.Length, length);
// S7 String结构
var result = new byte[2 + length];
result[0] = (byte)length; // MaxLength
result[1] = (byte)actualLen; // ActualLength
Array.Copy(strBytes, 0, result, 2, actualLen);
return result;
}
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
}
}
+341
View File
@@ -0,0 +1,341 @@
using Common.Results;
using Plc.Abstractions;
using Plc.Models;
using Plc.Utils;
using Plc.Options;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Plc.Services;
public class PlcMonitorService : IPlcService, IDisposable
{
private readonly IPlcDriver _driver;
private readonly IPlcOptimizer _optimizer;
private readonly IDataConverter _converter;
private readonly ILogger<PlcMonitorService>? _logger;
private readonly PlcMonitorOptions _options;
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
private Task? _monitorTask;
private CancellationTokenSource? _cts;
private readonly TimeSpan _refreshRate;
// 重连策略
private int _reconnectAttempts = 0;
private const int MaxReconnectAttempts = 10;
public PlcMonitorService(
IPlcDriver driver,
IPlcOptimizer optimizer,
IDataConverter converter,
IOptions<PlcMonitorOptions> options,
ILogger<PlcMonitorService>? logger = null)
{
_driver = driver;
_optimizer = optimizer;
_converter = converter;
_logger = logger;
_options = options.Value;
_refreshRate = _options.RefreshRate;
}
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_monitorTask != null)
{
_logger?.LogWarning("Monitor service is already running");
return;
}
_logger?.LogInformation("Starting PLC monitor service");
var connectResult = await _driver.ConnectAsync(cancellationToken);
if (connectResult.IsFailure)
{
_logger?.LogError("Failed to connect to PLC: {Error}", connectResult.ErrorMessage);
throw new InvalidOperationException($"Cannot start monitor service: {connectResult.ErrorMessage}");
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
_logger?.LogInformation("PLC monitor service started successfully");
}
public async Task StopAsync()
{
_logger?.LogInformation("Stopping PLC monitor service");
_cts?.Cancel();
if (_monitorTask != null)
{
try
{
await _monitorTask;
}
catch (OperationCanceledException)
{
_logger?.LogDebug("Monitor task cancelled successfully");
}
finally
{
_monitorTask = null;
}
}
if (_cts != null)
{
_cts.Dispose();
_cts = null;
}
await _driver.DisconnectAsync();
_logger?.LogInformation("PLC monitor service stopped");
}
public void Subscribe(PlcTag tag)
{
if (_subscriptions.TryAdd(tag.Id, tag))
{
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
}
else
{
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
}
}
public void Unsubscribe(string tagId)
{
if (_subscriptions.TryRemove(tagId, out _))
{
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
}
else
{
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
}
}
public async Task<Result<T>> ReadAsync<T>(string address)
{
_logger?.LogDebug("Reading from address {Address}", address);
// 临时读取(未优化)
int size = TypeHelper.GetSize(typeof(T));
var result = await _driver.ReadBytesAsync(address, (ushort)size);
if (result.IsFailure)
{
_logger?.LogError("Failed to read from {Address}: {Error}", address, result.ErrorMessage);
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
}
if (result.Value is null)
{
_logger?.LogError("Read from {Address} returned null data", address);
return Result.Failure<T>("Read returned null data");
}
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
if (val is null)
{
_logger?.LogError("Failed to convert data from {Address}", address);
return Result.Failure<T>("Conversion failed");
}
_logger?.LogDebug("Successfully read value from {Address}", address);
return Result.Success((T)val);
}
public async Task<Result> WriteAsync<T>(string address, T value)
{
if (value == null)
{
_logger?.LogWarning("Attempted to write null value to {Address}", address);
return Result.Failure("Cannot write null value");
}
_logger?.LogDebug("Writing to address {Address}", address);
int size = TypeHelper.GetSize(typeof(T));
var bytes = _converter.ToBytes(value, typeof(T), size);
if (bytes.Length == 0)
{
_logger?.LogError("Conversion to bytes failed for type {Type}", typeof(T).Name);
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
}
var result = await _driver.WriteBytesAsync(address, bytes);
if (result.IsFailure)
{
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
}
else
{
_logger?.LogDebug("Successfully wrote to {Address}", address);
}
return result;
}
private async Task MonitorLoop(CancellationToken token)
{
_logger?.LogInformation("Monitor loop started");
while (!token.IsCancellationRequested)
{
try
{
// 连接检查和重试逻辑
if (!_driver.IsConnected)
{
_logger?.LogWarning("PLC disconnected, attempting to reconnect (attempt {Attempt}/{Max})",
_reconnectAttempts + 1, MaxReconnectAttempts);
// 指数退避策略
var delay = Math.Min(1000 * Math.Pow(2, _reconnectAttempts), 30000);
await Task.Delay((int)delay, token);
var connectResult = await _driver.ConnectAsync(token);
if (connectResult.IsSuccess)
{
_logger?.LogInformation("Successfully reconnected to PLC");
_reconnectAttempts = 0;
}
else
{
_reconnectAttempts++;
_logger?.LogError("Reconnection failed: {Error}", connectResult.ErrorMessage);
if (_reconnectAttempts >= MaxReconnectAttempts)
{
_logger?.LogCritical("Max reconnection attempts reached, monitor loop will continue trying");
_reconnectAttempts = 0; // 重置以继续尝试
}
continue;
}
}
var currentTags = _subscriptions.Values.ToList();
if (currentTags.Count == 0)
{
await Task.Delay(1000, token);
continue;
}
// 优化:计算分桶
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
_logger?.LogTrace("Optimized {TagCount} tags into {BatchCount} batches",
currentTags.Count, batches.Count);
// 执行读取
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var batch in batches)
{
if (token.IsCancellationRequested) break;
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
if (readResult.IsSuccess && readResult.Value != null)
{
DistributeValues(batch, readResult.Value);
}
else
{
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
batch.StartAddress, readResult.ErrorMessage);
}
}
sw.Stop();
if (sw.Elapsed > _refreshRate)
{
_logger?.LogWarning("Monitor loop took {Elapsed} ms, which is longer than refresh rate {Rate} ms",
sw.Elapsed.TotalMilliseconds, _refreshRate.TotalMilliseconds);
}
}
catch (OperationCanceledException)
{
_logger?.LogDebug("Monitor loop cancelled");
break;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in monitor loop");
}
await Task.Delay(_refreshRate, token);
}
_logger?.LogInformation("Monitor loop stopped");
}
private void DistributeValues(PlcReadBatch batch, byte[] data)
{
if (batch.ParsedStartAddress == null)
{
_logger?.LogError("Batch missing parsed start address");
return;
}
var batchStart = batch.ParsedStartAddress;
int batchStartByte = batchStart.ByteOffset;
foreach (var tag in batch.Tags)
{
if (tag.ParsedAddress == null)
{
_logger?.LogWarning("Tag {TagId} missing parsed address", tag.Id);
continue;
}
var tagAddr = tag.ParsedAddress;
int tagStartByte = tagAddr.ByteOffset;
int relativeOffset = tagStartByte - batchStartByte;
// 检查边界
int typeSize = tag.DataType == typeof(string)
? TypeHelper.GetSize(tag.DataType, tag.Length)
: TypeHelper.GetSize(tag.DataType);
if (relativeOffset + typeSize <= data.Length)
{
try
{
// 传递位偏移量以支持bool类型
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length, tagAddr.BitOffset);
// 变更检测
if (!Equals(tag.Value, newVal))
{
tag.Value = newVal;
tag.OnChanged?.Invoke(tag, newVal);
_logger?.LogTrace("Tag {TagId} value changed to {Value}", tag.Id, newVal);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
tag.Id, tag.Address);
}
}
else
{
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
tag.Id, relativeOffset, typeSize, data.Length);
}
}
}
public void Dispose()
{
_logger?.LogDebug("Disposing PLC monitor service");
_cts?.Cancel();
_driver.Dispose();
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace Plc.Utils;
public static class TypeHelper
{
/// <summary>
/// 获取PLC数据类型的字节大小
/// </summary>
/// <param name="type">数据类型</param>
/// <returns>字节大小</returns>
/// <exception cref="ArgumentException">当类型不支持或需要明确长度时抛出</exception>
public static int GetSize(Type type)
{
if (type == typeof(bool)) return 1; // S7中bool按字节对齐
if (type == typeof(byte)) return 1;
if (type == typeof(short) || type == typeof(ushort)) return 2;
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
// 字符串类型需要明确指定长度
if (type == typeof(string))
{
throw new ArgumentException(
"String type requires explicit length specification. Use the overload with length parameter or specify length in PlcTag.",
nameof(type));
}
throw new NotSupportedException($"Type {type.Name} is not supported. Supported types: bool, byte, short, ushort, int, uint, float, long, ulong, double, string.");
}
/// <summary>
/// 获取PLC数据类型的字节大小(支持字符串长度)
/// </summary>
/// <param name="type">数据类型</param>
/// <param name="length">长度(对字符串类型有效)</param>
/// <returns>字节大小</returns>
public static int GetSize(Type type, int length)
{
if (type == typeof(string))
{
// S7 String = 2字节头部 + 实际字符数据
return 2 + length;
}
// 对于非字符串类型,忽略length参数
return GetSize(type);
}
}
@@ -0,0 +1,91 @@
using Common.Results;
using Ports.Models;
namespace Ports.Abstractions;
/// <summary>
/// 串口驱动接口
/// </summary>
public interface ISerialPortDriver : IDisposable
{
/// <summary>
/// 获取串口是否已打开
/// </summary>
bool IsOpen { get; }
/// <summary>
/// 获取端口名称
/// </summary>
string? PortName { get; }
/// <summary>
/// 打开串口连接
/// </summary>
/// <param name="options">配置选项</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// 关闭串口连接
/// </summary>
/// <returns>操作结果</returns>
Task<Result> CloseAsync();
/// <summary>
/// 读取指定数量的字节
/// </summary>
/// <param name="count">读取数量</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>读取的数据</returns>
Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default);
/// <summary>
/// 写入字节数据
/// </summary>
/// <param name="data">要写入的数据</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default);
/// <summary>
/// 读取一行数据(使用配置的换行符)
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>读取的字符串</returns>
Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 写入一行数据
/// </summary>
/// <param name="data">要写入的数据</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default);
/// <summary>
/// 读取所有可用数据
/// </summary>
/// <returns>读取的数据</returns>
Task<Result<byte[]>> ReadAvailableAsync();
/// <summary>
/// 清空输入缓冲区
/// </summary>
void ClearInputBuffer();
/// <summary>
/// 清空输出缓冲区
/// </summary>
void ClearOutputBuffer();
/// <summary>
/// 获取输入缓冲区中待读取的字节数
/// </summary>
int BytesToRead { get; }
/// <summary>
/// 获取输出缓冲区中待发送的字节数
/// </summary>
int BytesToWrite { get; }
}
@@ -0,0 +1,64 @@
using Common.Results;
using Ports.Models;
namespace Ports.Abstractions;
/// <summary>
/// 串口服务接口(高级功能)
/// </summary>
public interface ISerialPortService : IDisposable
{
/// <summary>
/// 获取连接状态
/// </summary>
ConnectionState State { get; }
/// <summary>
/// 获取端口名称
/// </summary>
string? PortName { get; }
/// <summary>
/// 启动监听
/// </summary>
/// <param name="options">配置选项</param>
/// <param name="autoReconnect">是否自动重连</param>
/// <returns>操作结果</returns>
Task<Result> StartListeningAsync(SerialPortOptions options, bool autoReconnect = false);
/// <summary>
/// 停止监听
/// </summary>
/// <returns>操作结果</returns>
Task<Result> StopListeningAsync();
/// <summary>
/// 发送数据(队列化)
/// </summary>
/// <param name="data">要发送的数据</param>
/// <returns>操作结果</returns>
Task<Result> SendAsync(byte[] data);
/// <summary>
/// 发送并等待响应(请求-响应模式)
/// </summary>
/// <param name="data">要发送的数据</param>
/// <param name="timeout">超时时间</param>
/// <returns>响应数据</returns>
Task<Result<byte[]>> SendAndReceiveAsync(byte[] data, TimeSpan timeout);
/// <summary>
/// 数据接收事件
/// </summary>
event EventHandler<DataReceivedEventArgs>? DataReceived;
/// <summary>
/// 错误事件
/// </summary>
event EventHandler<Models.ErrorEventArgs>? ErrorOccurred;
/// <summary>
/// 连接状态变化事件
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
}
@@ -0,0 +1,83 @@
using Microsoft.Extensions.DependencyInjection;
using Ports.Abstractions;
using Ports.Drivers;
using Ports.Services;
namespace Ports.DependencyInjection;
/// <summary>
/// 串口模块依赖注入扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加串口驱动(单例)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortDriver(this IServiceCollection services)
{
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
return services;
}
/// <summary>
/// 添加串口驱动(瞬时)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortDriverTransient(this IServiceCollection services)
{
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
return services;
}
/// <summary>
/// 添加串口服务(单例)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortService(this IServiceCollection services)
{
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
services.AddSingleton<ISerialPortService>(sp =>
{
var driver = sp.GetRequiredService<ISerialPortDriver>();
return new SerialPortService(driver);
});
return services;
}
/// <summary>
/// 添加串口服务(瞬时)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortServiceTransient(this IServiceCollection services)
{
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
services.AddTransient<ISerialPortService>(sp =>
{
var driver = sp.GetRequiredService<ISerialPortDriver>();
return new SerialPortService(driver);
});
return services;
}
/// <summary>
/// 添加命名串口服务(支持多实例)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="name">服务名称</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddNamedSerialPortService(this IServiceCollection services, string name)
{
services.AddKeyedTransient<ISerialPortDriver>(name, (sp, key) => new SerialPortDriver());
services.AddKeyedTransient<ISerialPortService>(name, (sp, key) =>
{
var driver = sp.GetRequiredKeyedService<ISerialPortDriver>(key);
return new SerialPortService(driver);
});
return services;
}
}
@@ -0,0 +1,229 @@
using System.IO.Ports;
using System.Management;
using Common.Results;
using Ports.Models;
namespace Ports.Discovery;
/// <summary>
/// 串口发现与查询工具
/// </summary>
public static class SerialPortDiscovery
{
/// <summary>
/// 获取所有可用串口
/// </summary>
/// <returns>串口信息列表</returns>
public static Result<List<SerialPortInfo>> GetAvailablePorts()
{
return Result.Try(() =>
{
var portNames = SerialPort.GetPortNames();
var portInfoList = new List<SerialPortInfo>();
foreach (var portName in portNames)
{
var portInfo = GetPortInfo(portName);
if (portInfo.IsSuccess && portInfo.Value != null)
{
portInfoList.Add(portInfo.Value);
}
else
{
// 如果无法获取详细信息,至少添加基本信息
portInfoList.Add(new SerialPortInfo
{
PortName = portName,
Description = "Unknown",
IsInUse = IsPortInUse(portName).Value
});
}
}
return portInfoList.OrderBy(p => p.PortName).ToList();
});
}
/// <summary>
/// 检查指定串口是否存在
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>是否存在</returns>
public static Result<bool> PortExists(string portName)
{
return Result.Try(() =>
{
if (string.IsNullOrWhiteSpace(portName))
return false;
var availablePorts = SerialPort.GetPortNames();
return availablePorts.Contains(portName, StringComparer.OrdinalIgnoreCase);
});
}
/// <summary>
/// 检查串口是否被占用
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>是否被占用</returns>
public static Result<bool> IsPortInUse(string portName)
{
return Result.Try(() =>
{
if (string.IsNullOrWhiteSpace(portName))
return false;
try
{
using var port = new SerialPort(portName);
port.Open();
port.Close();
return false; // 能够打开说明没有被占用
}
catch (UnauthorizedAccessException)
{
return true; // 无权访问说明被占用
}
catch (IOException)
{
return true; // IO异常也可能是被占用
}
catch
{
return false; // 其他异常认为不是被占用
}
});
}
/// <summary>
/// 获取串口详细信息
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>串口信息</returns>
public static Result<SerialPortInfo> GetPortInfo(string portName)
{
return Result.Try(() =>
{
var portInfo = new SerialPortInfo
{
PortName = portName,
IsInUse = IsPortInUse(portName).Value
};
// 仅在Windows平台获取详细信息
if (OperatingSystem.IsWindows())
{
try
{
EnrichPortInfoFromWmi(portInfo);
}
catch
{
// 如果WMI查询失败,返回基本信息
}
}
return portInfo;
});
}
/// <summary>
/// 获取USB转串口设备
/// </summary>
/// <returns>USB串口列表</returns>
public static Result<List<SerialPortInfo>> GetUsbSerialPorts()
{
return Result.Try(() =>
{
var allPorts = GetAvailablePorts();
if (allPorts.IsFailure || allPorts.Value == null)
return new List<SerialPortInfo>();
return allPorts.Value
.Where(p => p.IsUsbSerial)
.ToList();
});
}
/// <summary>
/// 通过WMI获取串口详细信息(仅Windows)
/// </summary>
private static void EnrichPortInfoFromWmi(SerialPortInfo portInfo)
{
if (!OperatingSystem.IsWindows())
return;
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT * FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
foreach (ManagementObject obj in searcher.Get())
{
var name = obj["Name"]?.ToString() ?? string.Empty;
// 检查是否是当前端口
if (!name.Contains(portInfo.PortName, StringComparison.OrdinalIgnoreCase))
continue;
portInfo.Description = name;
portInfo.DeviceId = obj["DeviceID"]?.ToString() ?? string.Empty;
portInfo.Manufacturer = obj["Manufacturer"]?.ToString() ?? string.Empty;
// 判断是否为USB设备
var deviceId = portInfo.DeviceId.ToUpperInvariant();
portInfo.IsUsbSerial = deviceId.Contains("USB");
// 提取VID和PID
if (portInfo.IsUsbSerial)
{
ExtractVidPid(portInfo.DeviceId, out var vid, out var pid);
portInfo.VendorId = vid;
portInfo.ProductId = pid;
}
break;
}
}
catch
{
// WMI查询失败时忽略
}
}
/// <summary>
/// 从设备ID中提取VID和PID
/// </summary>
private static void ExtractVidPid(string deviceId, out string vid, out string pid)
{
vid = string.Empty;
pid = string.Empty;
if (string.IsNullOrEmpty(deviceId))
return;
// 格式示例: USB\VID_0403&PID_6001\...
var vidIndex = deviceId.IndexOf("VID_", StringComparison.OrdinalIgnoreCase);
var pidIndex = deviceId.IndexOf("PID_", StringComparison.OrdinalIgnoreCase);
if (vidIndex >= 0)
{
var vidStart = vidIndex + 4;
var vidEnd = deviceId.IndexOf('&', vidStart);
if (vidEnd < 0) vidEnd = deviceId.IndexOf('\\', vidStart);
if (vidEnd < 0) vidEnd = deviceId.Length;
vid = deviceId.Substring(vidStart, vidEnd - vidStart);
}
if (pidIndex >= 0)
{
var pidStart = pidIndex + 4;
var pidEnd = deviceId.IndexOf('&', pidStart);
if (pidEnd < 0) pidEnd = deviceId.IndexOf('\\', pidStart);
if (pidEnd < 0) pidEnd = deviceId.Length;
pid = deviceId.Substring(pidStart, pidEnd - pidStart);
}
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.IO.Ports;
using Common.Results;
using Ports.Abstractions;
using Ports.Models;
namespace Ports.Drivers;
/// <summary>
/// 串口驱动实现
/// </summary>
public class SerialPortDriver : ISerialPortDriver
{
private SerialPort? _serialPort;
private SerialPortOptions? _options;
private readonly SemaphoreSlim _lock = new(1, 1);
private bool _disposed;
/// <inheritdoc />
public bool IsOpen => _serialPort?.IsOpen ?? false;
/// <inheritdoc />
public string? PortName => _serialPort?.PortName;
/// <inheritdoc />
public int BytesToRead => _serialPort?.BytesToRead ?? 0;
/// <inheritdoc />
public int BytesToWrite => _serialPort?.BytesToWrite ?? 0;
/// <inheritdoc />
public async Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default)
{
if (options == null)
return Result.Failure("配置选项不能为空");
await _lock.WaitAsync(cancellationToken);
try
{
if (IsOpen)
return Result.Failure("串口已经打开");
// 验证配置
var validation = ValidateOptions(options);
if (validation.IsFailure)
return validation;
_options = options;
_serialPort = new SerialPort
{
PortName = options.PortName,
BaudRate = options.BaudRate,
DataBits = options.DataBits,
StopBits = options.StopBits,
Parity = options.Parity,
Handshake = options.Handshake,
ReadTimeout = options.ReadTimeout,
WriteTimeout = options.WriteTimeout,
Encoding = options.Encoding,
NewLine = options.NewLine,
ReadBufferSize = options.ReceiveBufferSize,
WriteBufferSize = options.TransmitBufferSize
};
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.Open(), cancellationToken);
});
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public async Task<Result> CloseAsync()
{
await _lock.WaitAsync();
try
{
if (!IsOpen)
return Result.Success();
return await Result.TryAsync(async () =>
{
await Task.Run(() =>
{
_serialPort?.Close();
_serialPort?.Dispose();
_serialPort = null;
});
});
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public async Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure<byte[]>("串口未打开");
if (count <= 0)
return Result.Failure<byte[]>("读取数量必须大于0");
return await Result.TryAsync(async () =>
{
var buffer = new byte[count];
var totalRead = 0;
while (totalRead < count && !cancellationToken.IsCancellationRequested)
{
var bytesToRead = Math.Min(count - totalRead, _serialPort.BytesToRead);
if (bytesToRead > 0)
{
var read = await Task.Run(() =>
_serialPort.Read(buffer, totalRead, bytesToRead), cancellationToken);
totalRead += read;
}
else
{
await Task.Delay(10, cancellationToken); // 短暂等待数据
}
}
if (totalRead < count)
{
var actualData = new byte[totalRead];
Array.Copy(buffer, actualData, totalRead);
return actualData;
}
return buffer;
});
}
/// <inheritdoc />
public async Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure("串口未打开");
if (data == null || data.Length == 0)
return Result.Failure("数据不能为空");
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.Write(data, 0, data.Length), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure<string>("串口未打开");
return await Result.TryAsync(async () =>
{
return await Task.Run(() => _serialPort.ReadLine(), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure("串口未打开");
if (data == null)
return Result.Failure("数据不能为空");
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.WriteLine(data), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result<byte[]>> ReadAvailableAsync()
{
if (!IsOpen || _serialPort == null)
return Result.Failure<byte[]>("串口未打开");
return await Result.TryAsync(async () =>
{
var count = _serialPort.BytesToRead;
if (count == 0)
return Array.Empty<byte>();
var buffer = new byte[count];
await Task.Run(() => _serialPort.Read(buffer, 0, count));
return buffer;
});
}
/// <inheritdoc />
public void ClearInputBuffer()
{
if (IsOpen && _serialPort != null)
{
_serialPort.DiscardInBuffer();
}
}
/// <inheritdoc />
public void ClearOutputBuffer()
{
if (IsOpen && _serialPort != null)
{
_serialPort.DiscardOutBuffer();
}
}
/// <summary>
/// 验证配置选项
/// </summary>
private Result ValidateOptions(SerialPortOptions options)
{
if (string.IsNullOrWhiteSpace(options.PortName))
return Result.Failure("端口名称不能为空");
if (options.BaudRate <= 0)
return Result.Failure("波特率必须大于0");
if (options.DataBits < 5 || options.DataBits > 8)
return Result.Failure("数据位必须在5-8之间");
if (options.ReadTimeout < 0)
return Result.Failure("读取超时不能为负数");
if (options.WriteTimeout < 0)
return Result.Failure("写入超时不能为负数");
return Result.Success();
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_lock.Wait();
try
{
_serialPort?.Close();
_serialPort?.Dispose();
_serialPort = null;
_disposed = true;
}
finally
{
_lock.Release();
_lock.Dispose();
}
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,239 @@
using Ports.Discovery;
using Ports.Drivers;
using Ports.Models;
using Ports.Services;
using Ports.Utils;
namespace Ports.Examples;
/// <summary>
/// 串口通信模块使用示例
/// </summary>
public class SerialPortExamples
{
/// <summary>
/// 示例1:发现串口
/// </summary>
public static void Example01_DiscoverPorts()
{
Console.WriteLine("=== 示例1:发现串口 ===\n");
// 获取所有可用串口
var portsResult = SerialPortDiscovery.GetAvailablePorts();
if (portsResult.IsSuccess && portsResult.Value != null)
{
Console.WriteLine($"找到 {portsResult.Value.Count} 个串口:\n");
foreach (var port in portsResult.Value)
{
Console.WriteLine($"端口: {port.PortName}");
Console.WriteLine($" 描述: {port.Description}");
Console.WriteLine($" 制造商: {port.Manufacturer}");
Console.WriteLine($" USB设备: {port.IsUsbSerial}");
Console.WriteLine($" 被占用: {port.IsInUse}");
if (port.IsUsbSerial)
{
Console.WriteLine($" VID: {port.VendorId}, PID: {port.ProductId}");
}
Console.WriteLine();
}
}
}
/// <summary>
/// 示例2:使用驱动层直接通信
/// </summary>
public static async Task Example02_UseDriver()
{
Console.WriteLine("=== 示例2:使用驱动层通信 ===\n");
using var driver = new SerialPortDriver();
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600,
DataBits = 8
};
// 打开连接
var openResult = await driver.OpenAsync(options);
if (openResult.IsSuccess)
{
Console.WriteLine("串口打开成功");
// 发送数据
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var sendResult = await driver.WriteBytesAsync(data);
if (sendResult.IsSuccess)
{
Console.WriteLine("数据发送成功");
// 等待响应
await Task.Delay(100);
// 读取响应
if (driver.BytesToRead > 0)
{
var readResult = await driver.ReadAvailableAsync();
if (readResult.IsSuccess && readResult.Value != null)
{
Console.WriteLine($"接收到 {readResult.Value.Length} 字节");
var hexResult = SerialDataConverter.BytesToHexString(readResult.Value);
Console.WriteLine($"数据: {hexResult.Value}");
}
}
}
await driver.CloseAsync();
}
else
{
Console.WriteLine($"打开串口失败: {openResult.ErrorMessage}");
}
}
/// <summary>
/// 示例3:使用服务层监听数据
/// </summary>
public static async Task Example03_UseService()
{
Console.WriteLine("=== 示例3:使用服务层监听 ===\n");
using var service = new SerialPortService(new SerialPortDriver());
// 订阅事件
service.DataReceived += (sender, e) =>
{
Console.WriteLine($"[{e.ReceivedAt:HH:mm:ss}] 收到数据: {e.Data.Length} 字节");
var hex = SerialDataConverter.BytesToHexString(e.Data);
Console.WriteLine($" 数据: {hex.Value}");
};
service.ErrorOccurred += (sender, e) =>
{
Console.WriteLine($"[错误] {e.ErrorMessage}");
};
service.ConnectionStateChanged += (sender, e) =>
{
Console.WriteLine($"[状态] {e.PreviousState} -> {e.CurrentState}");
};
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600
};
// 启动监听(支持自动重连)
var startResult = await service.StartListeningAsync(options, autoReconnect: true);
if (startResult.IsSuccess)
{
Console.WriteLine("监听已启动,按任意键发送测试数据...");
Console.ReadKey();
// 发送测试数据
await service.SendAsync(new byte[] { 0x01, 0x02, 0x03 });
Console.WriteLine("按任意键停止监听...");
Console.ReadKey();
await service.StopListeningAsync();
}
}
/// <summary>
/// 示例4:数据转换
/// </summary>
public static void Example04_DataConversion()
{
Console.WriteLine("=== 示例4:数据转换 ===\n");
// 整数转字节
var int32Bytes = SerialDataConverter.Int32ToBytes(12345, isBigEndian: true);
Console.WriteLine($"整数 12345 -> 字节: {SerialDataConverter.BytesToHexString(int32Bytes.Value!).Value}");
// 字节转整数
var intValue = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian: true);
Console.WriteLine($"字节 -> 整数: {intValue.Value}");
// 十六进制字符串
var hexBytes = SerialDataConverter.HexStringToBytes("01 02 03 04");
Console.WriteLine($"十六进制 '01 02 03 04' -> 字节数组: {hexBytes.Value!.Length} 字节");
// 字符串转换
var strBytes = SerialDataConverter.StringToBytes("Hello");
Console.WriteLine($"字符串 'Hello' -> 字节: {SerialDataConverter.BytesToHexString(strBytes.Value!).Value}");
}
/// <summary>
/// 示例5:校验计算
/// </summary>
public static void Example05_Checksum()
{
Console.WriteLine("=== 示例5:校验计算 ===\n");
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
// CRC16(Modbus)
var crc16 = SerialChecksumHelper.CalculateCrc16(data);
Console.WriteLine($"CRC16: 0x{crc16.Value:X4}");
// LRC
var lrc = SerialChecksumHelper.CalculateLrc(data);
Console.WriteLine($"LRC: 0x{lrc.Value:X2}");
// XOR
var xor = SerialChecksumHelper.CalculateXor(data);
Console.WriteLine($"XOR: 0x{xor.Value:X2}");
// 累加和
var checksum = SerialChecksumHelper.CalculateChecksum(data);
Console.WriteLine($"Checksum: 0x{checksum.Value:X2}");
}
/// <summary>
/// 示例6:诊断工具
/// </summary>
public static async Task Example06_Diagnostics()
{
Console.WriteLine("=== 示例6:诊断工具 ===\n");
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600
};
// 测试连接
var testResult = await SerialPortDiagnostics.TestConnectionAsync(
"COM1",
options,
testData: new byte[] { 0x01, 0x02 }
);
if (testResult.IsSuccess)
{
Console.WriteLine("连接测试结果:");
Console.WriteLine(testResult.Value);
}
// 批量测试波特率
var baudRateResults = await SerialPortDiagnostics.TestBaudRatesAsync(
"COM1",
9600, 19200, 38400, 115200
);
if (baudRateResults.IsSuccess)
{
Console.WriteLine("\n波特率测试结果:");
foreach (var kvp in baudRateResults.Value!)
{
Console.WriteLine($" {kvp.Key} bps: {(kvp.Value ? "✓" : "✗")}");
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// 全局命名空间引入
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,58 @@
namespace Ports.Models;
/// <summary>
/// 连接状态
/// </summary>
public enum ConnectionState
{
/// <summary>
/// 已断开
/// </summary>
Disconnected,
/// <summary>
/// 连接中
/// </summary>
Connecting,
/// <summary>
/// 已连接
/// </summary>
Connected,
/// <summary>
/// 断开连接中
/// </summary>
Disconnecting,
/// <summary>
/// 错误
/// </summary>
Error
}
/// <summary>
/// 连接状态变化事件参数
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// 之前的状态
/// </summary>
public ConnectionState PreviousState { get; init; }
/// <summary>
/// 当前状态
/// </summary>
public ConnectionState CurrentState { get; init; }
/// <summary>
/// 变化时间
/// </summary>
public DateTime ChangedAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
@@ -0,0 +1,22 @@
namespace Ports.Models;
/// <summary>
/// 数据接收事件参数
/// </summary>
public class DataReceivedEventArgs : EventArgs
{
/// <summary>
/// 接收到的数据
/// </summary>
public required byte[] Data { get; init; }
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace Ports.Models;
/// <summary>
/// 错误事件参数
/// </summary>
public class ErrorEventArgs : EventArgs
{
/// <summary>
/// 异常信息
/// </summary>
public required Exception Exception { get; init; }
/// <summary>
/// 错误消息
/// </summary>
public string ErrorMessage => Exception.Message;
/// <summary>
/// 发生时间
/// </summary>
public DateTime OccurredAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
+47
View File
@@ -0,0 +1,47 @@
namespace Ports.Models;
/// <summary>
/// 串口信息模型
/// </summary>
public class SerialPortInfo
{
/// <summary>
/// 端口名称(如 COM1, COM2)
/// </summary>
public required string PortName { get; set; }
/// <summary>
/// 端口描述
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 制造商
/// </summary>
public string Manufacturer { get; set; } = string.Empty;
/// <summary>
/// 设备ID
/// </summary>
public string DeviceId { get; set; } = string.Empty;
/// <summary>
/// 是否被占用
/// </summary>
public bool IsInUse { get; set; }
/// <summary>
/// 厂商ID(VID)
/// </summary>
public string VendorId { get; set; } = string.Empty;
/// <summary>
/// 产品ID(PID)
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 是否为USB转串口设备
/// </summary>
public bool IsUsbSerial { get; set; }
}
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff. Show more